[GH-ISSUE #1184] Feat: Audit Logging Plugin #8632

Closed
opened 2026-04-13 03:46:21 -05:00 by GiteaMirror · 23 comments
Owner

Originally created by @ping-maxwell on GitHub (Jan 11, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/1184

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

Better logging across all events that relate to Better Auth.

Describe the solution you'd like

Tracks and logs authentication events, such as login attempts, password changes, and account lockouts for security monitoring.

Describe alternatives you've considered

Manually coding it.
But I believe that this would be greater for the rest of the community if developed for everyone, instead of everyone developing their own version.

Additional context

FYI: I could help develop this.
Just want approval if this idea is good enough to be apart of Better Auth's internal plugins.

Originally created by @ping-maxwell on GitHub (Jan 11, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/1184 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [X] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. Better logging across all events that relate to Better Auth. ### Describe the solution you'd like Tracks and logs authentication events, such as login attempts, password changes, and account lockouts for security monitoring. ### Describe alternatives you've considered Manually coding it. But I believe that this would be greater for the rest of the community if developed for everyone, instead of everyone developing their own version. ### Additional context FYI: I could help develop this. Just want approval if this idea is good enough to be apart of Better Auth's internal plugins.
GiteaMirror added the locked label 2026-04-13 03:46:21 -05:00
Author
Owner

@lucafaggianelli commented on GitHub (Mar 7, 2025):

Hey I think it's a great idea, I'd to work on the plugin as well.

On my side I'm interested in both standard logging and audit logging to be sent to an external platform for audit logs immutability for compliance with security frameworks (like ISO27001 and SOC2), I'm wondering if you have the same requirements

<!-- gh-comment-id:2707004212 --> @lucafaggianelli commented on GitHub (Mar 7, 2025): Hey I think it's a great idea, I'd to work on the plugin as well. On my side I'm interested in both standard logging and audit logging to be sent to an external platform for audit logs immutability for compliance with security frameworks (like ISO27001 and SOC2), I'm wondering if you have the same requirements
Author
Owner

@lucafaggianelli commented on GitHub (Mar 7, 2025):

I'm looking into the after/before hooks config, do you think it would be enough for catching all auth events? On the other hand I'm thinking that actual logging might occur differently depending on the app, so probably it would be nice if the logging plugin just exposes an event callback and then the user can choose what to do with the event, ie logging it, send event to analytics etc.

<!-- gh-comment-id:2707185268 --> @lucafaggianelli commented on GitHub (Mar 7, 2025): I'm looking into the after/before hooks config, do you think it would be enough for catching all auth events? On the other hand I'm thinking that actual logging might occur differently depending on the app, so probably it would be nice if the logging plugin just exposes an event callback and then the user can choose what to do with the event, ie logging it, send event to analytics etc.
Author
Owner

@dosubot[bot] commented on GitHub (Jun 15, 2025):

Hi, @ping-maxwell. I'm Dosu, and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale.

Issue Summary:

  • Proposal for an audit logging plugin to enhance security monitoring by logging authentication events.
  • Collaboration interest from @lucafaggianelli, emphasizing compliance with standards like ISO27001 and SOC2.
  • Discussion on using hooks for capturing auth events and enabling user-defined callbacks for flexible logging.

Next Steps:

  • Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here.
  • If no updates are provided, the issue will be automatically closed in 7 days.

Thank you for your understanding and contribution!

<!-- gh-comment-id:2974147396 --> @dosubot[bot] commented on GitHub (Jun 15, 2025): Hi, @ping-maxwell. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale. **Issue Summary:** - Proposal for an audit logging plugin to enhance security monitoring by logging authentication events. - Collaboration interest from @lucafaggianelli, emphasizing compliance with standards like ISO27001 and SOC2. - Discussion on using hooks for capturing auth events and enabling user-defined callbacks for flexible logging. **Next Steps:** - Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here. - If no updates are provided, the issue will be automatically closed in 7 days. Thank you for your understanding and contribution!
Author
Owner

@ping-maxwell commented on GitHub (Jun 15, 2025):

relevant

<!-- gh-comment-id:2974392581 --> @ping-maxwell commented on GitHub (Jun 15, 2025): relevant
Author
Owner

@FalconiZzare commented on GitHub (Sep 14, 2025):

Much needed. It would also benefit if we integrate a way of inserting logs for events that happen outside of Better Auth. For example an admin updated something from a sensitive table that is not part of Better Auth schema. Then we can pass the headers to auth.api.insertLog("This person did something here).

Hopefully we get this plugin.

<!-- gh-comment-id:3289641440 --> @FalconiZzare commented on GitHub (Sep 14, 2025): Much needed. It would also benefit if we integrate a way of inserting logs for events that happen outside of Better Auth. For example an admin updated something from a sensitive table that is not part of Better Auth schema. Then we can pass the headers to auth.api.insertLog("This person did something here). Hopefully we get this plugin.
Author
Owner

@Kaseax commented on GitHub (Oct 4, 2025):

I would love to see this! Also, @FalconiZzare Idea about inserting custom events is also a really good addition

<!-- gh-comment-id:3368120493 --> @Kaseax commented on GitHub (Oct 4, 2025): I would love to see this! Also, @FalconiZzare Idea about inserting custom events is also a really good addition
Author
Owner

@dosubot[bot] commented on GitHub (Jan 3, 2026):

Hi, @ping-maxwell. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You proposed an Audit Logging Plugin for Better Auth to track authentication events for enhanced security.
  • Contributor lucafaggianelli showed interest, emphasizing compliance needs like ISO27001 and SOC2, and suggested using hooks with user-defined callbacks for flexible logging.
  • The issue was marked stale previously but you confirmed it is still relevant.
  • Additional community members FalconiZzare and Kaseax supported the idea, suggesting extending logging to custom events beyond Better Auth.
  • The discussion focuses on creating a versatile audit logging solution that benefits the community.

Next Steps:

  • Please let me know if this issue is still relevant to the latest version of better-auth by commenting below to keep the discussion open.
  • If I do not hear back within 7 days, this issue will be automatically closed.

Thank you for your understanding and contribution!

<!-- gh-comment-id:3707165058 --> @dosubot[bot] commented on GitHub (Jan 3, 2026): Hi, @ping-maxwell. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You proposed an Audit Logging Plugin for Better Auth to track authentication events for enhanced security. - Contributor lucafaggianelli showed interest, emphasizing compliance needs like ISO27001 and SOC2, and suggested using hooks with user-defined callbacks for flexible logging. - The issue was marked stale previously but you confirmed it is still relevant. - Additional community members FalconiZzare and Kaseax supported the idea, suggesting extending logging to custom events beyond Better Auth. - The discussion focuses on creating a versatile audit logging solution that benefits the community. **Next Steps:** - Please let me know if this issue is still relevant to the latest version of better-auth by commenting below to keep the discussion open. - If I do not hear back within 7 days, this issue will be automatically closed. Thank you for your understanding and contribution!
Author
Owner

@waldothedeveloper commented on GitHub (Jan 31, 2026):

relevant!

<!-- gh-comment-id:3827367572 --> @waldothedeveloper commented on GitHub (Jan 31, 2026): relevant!
Author
Owner

@josepchetrit12 commented on GitHub (Feb 11, 2026):

Hey I'm interested on that, any update?

<!-- gh-comment-id:3887581433 --> @josepchetrit12 commented on GitHub (Feb 11, 2026): Hey I'm interested on that, any update?
Author
Owner

@Re4GD commented on GitHub (Feb 12, 2026):

Hello, I have developed a basic audit-log plugin that i am currently testing in my own apps. It is essentially a before/after hook plugin. I have tried to make it as generic as possible such as adding retention and putting toggles for data capture etc. It is mostly finished but it would be nice to polish the API.

Features:

  • Before/after hooks
  • Capture before/after bodies
  • Retention config for log cleanup
  • Data redaction PII, different redaction strategies
  • Non blocking mode

I still don't know if there will be a first party audit logging plugin so I will be pasting the code here. I can go forward with a PR if this can evolve into that.

Here are the missing pieces, and the things I am planning to add:

  • Query, export, analyze, clean endpoints. These would be server endpoints with a custom callback to determine if the requester has auth. access: (ctx) => ctx.session.user.role === "admin", then allow these endpoints.
  • Client available endpoints for the one above so people can build out their admin dashboards.
  • Exporting/writing to S3 and other audit platforms. Currently only storage is "database". I would like to have a solid custom storage api with overridable read/write functions so people can implement their own storages.
  • Maybe add soc2/gdpr etc presets. But I decided not to add because it would confuse people that which toggles are flipped.
  • It is still not clear to me that what overriding the paths option does. I am still looking for a better whitelist/blacklist config for paths. But I am satisfied with the string | { path: string, config: PathConfig} API that allows the granular control for each path.
  • Some endpoints such as signout and delete causes descructive events, meaning that data is available on before but not after. Hence I would like to specify in the API that some paths are before only.
  • I am still not satisfied with the main plugin config. It could be better grouped such as capture: { ipAddress: boolean, userAgent: boolean, beforeBody: boolean, afterBody: boolean }. It is currently captureIpAddress, captureUserAgent etc. Same for other fields.
  • Normalizing actions, meaning that I am currently normalizing /sign-in/email to sign-in:email. I would like to have it normalized to user:sign-in:email, maybe I should add a config to paths something like normalizedEvent?: string that would convert that specific endpoint to user:sign-in:email. This would be better for filterability, admin events, organization events etc
  • Better error capturing for failed events
  • I still do not know how to structure the severity field. I know that for example a user delete is high, but what about other events. Also the defaults for each path is still vague to me. Even the severity field is still vague to me, I don't know how to proceed with that.
  • I would like to improve the additional fields types and api so people can add additional fields to this

Here is the code (it may be broken in some places as I had to delete some private business logic):

import type { BetterAuthPlugin } from "better-auth";
import { createHash } from "@better-auth/utils/hash";
import { createRandomStringGenerator } from "@better-auth/utils/random";
import {
  createAuthMiddleware,
  getIp,
  getSessionFromCtx,
} from "better-auth/api";

const generateId = createRandomStringGenerator("a-z", "A-Z", "0-9");

/**
 * Audit event record stored in database
 */
export interface AuditEvent {
  id: string;
  eventType: string;
  status: "success" | "failure" | "pending";
  severity: "low" | "medium" | "high" | "critical";
  userId?: string;
  actorUserId?: string;
  ipAddress?: string;
  userAgent?: string;
  resource?: string;
  action: string;
  metadata?: Record<string, any>;
  beforeState?: Record<string, any>;
  afterState?: Record<string, any>;
  error?: {
    message: string;
    code?: string;
    status?: number;
  };
  requestId?: string;
  organizationId?: string;
  createdAt: Date;
  updatedAt: Date;
}

/**
 * PII redaction configuration for common fields
 */
export interface PIIRedactionConfig {
  enabled: boolean;
  fields?: Array<
    | "password"
    | "token"
    | "secret"
    | "apiKey"
    | "refreshToken"
    | "accessToken"
    | "creditCard"
    | "cardNumber"
    | "cvv"
    | "cvv2"
    | "cvc"
    | "expiryDate"
    | "cardholderName"
    | "pan"
    | "trackData"
    | "track1"
    | "track2"
    | "pinBlock"
    | "securityCode"
    | "cardVerificationValue"
    | "ssn"
    | "medicalRecordNumber"
    | "healthPlanNumber"
    | "accountNumber"
    | "certificateNumber"
    | "licenseNumber"
    | "vehicleId"
    | "deviceId"
    | "biometricId"
    | "email"
    | "phone"
    | "phoneNumber"
    | "faxNumber"
    | "dateOfBirth"
    | "address"
    | "postalCode"
    | "city"
    | "country"
    | "nationalId"
    | "passport"
    | "ipAddress"
    | "name"
    | "firstName"
    | "lastName"
    | (string & {})
  >;
  strategy?: "mask" | "hash" | "remove";
  customRedactor?: (value: any, field: string) => any;
}

/**
 * Default PII fields redacted when no fields specified
 */
const DEFAULT_PII_FIELDS = [
  "password",
  "token",
  "secret",
  "apiKey",
  "refreshToken",
  "accessToken",
  "cardNumber",
  "cvv",
  "cvv2",
  "cvc",
  "pan",
  "ssn",
  "medicalRecordNumber",
] as const;

/**
 * Retention policy configuration
 */
export interface RetentionConfig {
  enabled: boolean;
  retentionDays: number;
}

/**
 * Path-specific logging configuration
 */
export interface PathConfig {
  logBefore?: boolean;
  logAfter?: boolean;
  captureState?: boolean;
  severity?: "low" | "medium" | "high" | "critical";
}

/**
 * Plugin configuration options
 */
export interface AuditLogOptions {
  enabled?: boolean;
  nonBlocking?: boolean;
  schema?: {
    modelName?: string;
    fields?: {
      id?: string;
      createdAt?: string;
      updatedAt?: string;
      eventType?: string;
      status?: string;
      severity?: string;
      userId?: string;
      actorUserId?: string;
      ipAddress?: string;
      userAgent?: string;
      resource?: string;
      action?: string;
      metadata?: string;
      beforeState?: string;
      afterState?: string;
      error?: string;
      requestId?: string;
      organizationId?: string;
    };
    additionalFields?: Record<
      string,
      {
        type: "string" | "number" | "boolean" | "date";
        required?: boolean;
        unique?: boolean;
        defaultValue?: any;
        references?: { model: string; field: string };
      }
    >;
  };
  piiRedaction?: PIIRedactionConfig;
  retention?: RetentionConfig;
  captureIpAddress?: boolean;
  captureUserAgent?: boolean;
  captureBeforeState?: boolean;
  captureAfterState?: boolean;
  paths?: (string | { path: string; config?: PathConfig })[];
  beforeLog?: (event: AuditEvent) => Promise<AuditEvent | null>;
  afterLog?: (event: AuditEvent) => Promise<void>;
}

interface EventContext {
  path: string;
  method: string;
  userId?: string;
  ipAddress?: string;
  userAgent?: string;
  requestId?: string;
  organizationId?: string;
  beforeState?: any;
}

/**
 * Converts API path to event type
 * @example "/sign-in/email" to "sign-in:email"
 */
function pathToEventType(path: string): string {
  return path.replace(/^\//, "").replace(/\//g, ":");
}

/**
 * Determines severity based on event type and status
 */
function getSeverity(
  eventType: string,
  status: string,
): "low" | "medium" | "high" | "critical" {
  if (status === "failure") return "medium";

  if (eventType.includes("delete") || eventType.includes("ban"))
    return "critical";
  if (eventType.includes("admin") || eventType.includes("role")) return "high";
  if (eventType.includes("sign-in") || eventType.includes("sign-out"))
    return "medium";

  return "low";
}

/**
 * Redacts PII from data object according to config
 */
async function redactPII(data: any, config: PIIRedactionConfig): Promise<any> {
  if (!data || typeof data !== "object") return data;
  if (!config.enabled) return data;

  const fields = config.fields || DEFAULT_PII_FIELDS;
  const strategy = config.strategy || "hash";
  const redacted = { ...data };

  for (const field of fields) {
    if (field in redacted) {
      if (config.customRedactor) {
        redacted[field] = config.customRedactor(redacted[field], field);
      } else if (strategy === "hash") {
        redacted[field] = await createHash("SHA-256", redacted[field]);
      } else if (strategy === "mask") {
        redacted[field] = "***REDACTED***";
      } else if (strategy === "remove") {
        delete redacted[field];
      }
    }
  }

  return redacted;
}

/**
 * Creates the audit log plugin
 */
export const auditLog = (options: AuditLogOptions = {}) => {
  const opts: Required<Omit<AuditLogOptions, "pathConfig">> & {
    pathConfig: Record<string, PathConfig>;
  } = {
    enabled: true,
    nonBlocking: false,
    piiRedaction: {
      enabled: options.piiRedaction?.enabled || false,
      fields: options.piiRedaction?.fields || (DEFAULT_PII_FIELDS as any),
      strategy: options.piiRedaction?.strategy || "hash",
      customRedactor: options.piiRedaction?.customRedactor,
    },
    retention: { 
      enabled: false, 
      retentionDays: 90 
    },
    captureIpAddress: true,
    captureUserAgent: true,
    captureBeforeState: true,
    captureAfterState: true,
    paths: [],
    pathConfig: {},
    beforeLog: async (event) => event,
    afterLog: async () => {},
    ...options,
    schema: {
      modelName: "auditLog",
      fields: {},
      additionalFields: {},
      ...options.schema,
    },
  };

  const modelName = opts.schema.modelName || "auditLog";
  const fieldNames = {
    id: opts.schema.fields?.id || "id",
    eventType: opts.schema.fields?.eventType || "eventType",
    status: opts.schema.fields?.status || "status",
    severity: opts.schema.fields?.severity || "severity",
    userId: opts.schema.fields?.userId || "userId",
    actorUserId: opts.schema.fields?.actorUserId || "actorUserId",
    ipAddress: opts.schema.fields?.ipAddress || "ipAddress",
    userAgent: opts.schema.fields?.userAgent || "userAgent",
    resource: opts.schema.fields?.resource || "resource",
    action: opts.schema.fields?.action || "action",
    metadata: opts.schema.fields?.metadata || "metadata",
    beforeState: opts.schema.fields?.beforeState || "beforeState",
    afterState: opts.schema.fields?.afterState || "afterState",
    error: opts.schema.fields?.error || "error",
    requestId: opts.schema.fields?.requestId || "requestId",
    organizationId: opts.schema.fields?.organizationId || "organizationId",
    createdAt: opts.schema.fields?.createdAt || "createdAt",
    updatedAt: opts.schema.fields?.updatedAt || "updatedAt",
  };

  if (opts.paths && opts.paths.length > 0) {
    opts.paths.forEach((p) => {
      if (typeof p === "string") {
        opts.pathConfig[p] = { logBefore: true, logAfter: true };
      } else {
        opts.pathConfig[p.path] = p.config || {
          logBefore: true,
          logAfter: true,
        };
      }
    });
  }

  const eventContextMap = new WeakMap<any, EventContext>();

  /**
   * Logs an audit event to the database
   */
  async function logEvent(ctx: any, event: Partial<AuditEvent>): Promise<void> {
    if (!opts.enabled) return;

    const now = new Date();

    let completeEvent: AuditEvent = {
      id: generateId(32),
      createdAt: now,
      updatedAt: now,
      status: event.status || "success",
      severity:
        event.severity ||
        getSeverity(event.eventType || "", event.status || "success"),
      eventType: event.eventType || "unknown",
      action: event.action || event.eventType || "unknown",
      ...event,
    } as AuditEvent;

    if (opts.piiRedaction.enabled) {
      completeEvent.metadata = await redactPII(
        completeEvent.metadata,
        opts.piiRedaction,
      );
      completeEvent.beforeState = await redactPII(
        completeEvent.beforeState,
        opts.piiRedaction,
      );
      completeEvent.afterState = await redactPII(
        completeEvent.afterState,
        opts.piiRedaction,
      );
    }

    const modified = await opts.beforeLog(completeEvent);
    if (!modified) return;
    completeEvent = modified;

    if (opts.nonBlocking) {
      writeEvent(ctx, completeEvent).catch((err) =>
        ctx.context.logger?.error("Non-blocking log write failed", err),
      );
      opts
        .afterLog(completeEvent)
        .catch((err) =>
          ctx.context.logger?.error("Non-blocking afterLog failed", err),
        );
    } else {
      await writeEvent(ctx, completeEvent);
      await opts.afterLog(completeEvent);
    }
  }

  /**
   * Writes event to database
   */
  async function writeEvent(ctx: any, event: AuditEvent): Promise<void> {
    const data: Record<string, any> = {};

    data[fieldNames.id] = event.id;
    data[fieldNames.eventType] = event.eventType;
    data[fieldNames.status] = event.status;
    data[fieldNames.severity] = event.severity;
    data[fieldNames.userId] = event.userId;
    data[fieldNames.actorUserId] = event.actorUserId;
    data[fieldNames.ipAddress] = event.ipAddress;
    data[fieldNames.userAgent] = event.userAgent;
    data[fieldNames.resource] = event.resource;
    data[fieldNames.action] = event.action;
    data[fieldNames.metadata] = JSON.stringify(event.metadata || {});
    data[fieldNames.beforeState] = JSON.stringify(event.beforeState || {});
    data[fieldNames.afterState] = JSON.stringify(event.afterState || {});
    data[fieldNames.error] = event.error ? JSON.stringify(event.error) : null;
    data[fieldNames.requestId] = event.requestId;
    data[fieldNames.organizationId] = event.organizationId;
    data[fieldNames.createdAt] = event.createdAt;
    data[fieldNames.updatedAt] = event.updatedAt;

    try {
      await ctx.context.adapter.create({
        model: modelName,
        data,
      });
    } catch (error) {
      ctx.context.logger?.error("Failed to write audit log", error);
      throw error;
    }
  }

  return {
    id: "auditLog",
    schema: {
      [modelName]: {
        fields: {
          [fieldNames.id]: { type: "string", required: true, unique: true },
          [fieldNames.eventType]: { type: "string", required: true },
          [fieldNames.status]: { type: "string", required: true },
          [fieldNames.severity]: { type: "string", required: true },
          [fieldNames.userId]: {
            type: "string",
            required: false,
            references: { model: "user", field: "id" },
          },
          [fieldNames.actorUserId]: {
            type: "string",
            required: false,
            references: { model: "user", field: "id" },
          },
          [fieldNames.ipAddress]: { type: "string", required: false },
          [fieldNames.userAgent]: { type: "string", required: false },
          [fieldNames.resource]: { type: "string", required: false },
          [fieldNames.action]: { type: "string", required: false },
          [fieldNames.metadata]: { type: "string", required: false },
          [fieldNames.beforeState]: { type: "string", required: false },
          [fieldNames.afterState]: { type: "string", required: false },
          [fieldNames.error]: { type: "string", required: false },
          [fieldNames.requestId]: { type: "string", required: false },
          [fieldNames.organizationId]: { type: "string", required: false },
          [fieldNames.createdAt]: { type: "date", required: true },
          [fieldNames.updatedAt]: { type: "date", required: true },
          ...opts.schema.additionalFields,
        },
      },
    },
    hooks: {
      before: [
        {
          matcher: (context) => {
            // Only log POST requests (data-modifying operations)
            if (context.method !== "POST") {
              return false;
            }

            // If no paths configured, match all POST requests
            if (Object.keys(opts.pathConfig).length === 0) {
              return true;
            }

            // Otherwise only match configured POST paths
            return (
              context.path !== undefined && context.path in opts.pathConfig
            );
          },
          handler: createAuthMiddleware(async (ctx) => {
            const path = ctx.path;
            const method = ctx.method;

            let session;
            try {
              session = await getSessionFromCtx(ctx);
            } catch (e) {
              // No session
            }

            const eventContext: EventContext = {
              path,
              method,
              userId: session?.user?.id,
              requestId: generateId(16),
            };

            if (opts.captureIpAddress && ctx.request) {
              const ip = getIp(ctx.request, ctx.context.options);
              if (ip) {
                eventContext.ipAddress = ip;
              }
            }

            if (opts.captureUserAgent) {
              eventContext.userAgent =
                ctx.headers?.get("user-agent") || undefined;
            }

            const pathConfig = opts.pathConfig[path];
            if (
              (opts.captureBeforeState || pathConfig?.captureState) &&
              ctx.body
            ) {
              eventContext.beforeState = { ...ctx.body };
            }

            eventContextMap.set(ctx, eventContext);

            const shouldLogBefore =
              pathConfig?.logBefore ||
              path.includes("sign-out") ||
              path.includes("signout");

            if (shouldLogBefore && session) {
              const eventType = pathToEventType(path);

              const logPromise = logEvent(ctx, {
                eventType,
                userId: eventContext.userId,
                ipAddress: eventContext.ipAddress,
                userAgent: eventContext.userAgent,
                requestId: eventContext.requestId,
                action: `${method} ${path}`,
                status: "pending",
                severity: pathConfig?.severity,
                metadata: { phase: "before" },
              });

              if (!opts.nonBlocking) {
                await logPromise;
              }
            }

            return { context: ctx };
          }),
        },
      ],

      after: [
        {
          matcher: (context) => {
            // Only log POST requests (data-modifying operations)
            if (context.method !== "POST") {
              return false;
            }

            // If no paths configured, match all POST requests
            if (Object.keys(opts.pathConfig).length === 0) {
              return true;
            }

            // Otherwise only match configured POST paths
            return (
              context.path !== undefined && context.path in opts.pathConfig
            );
          },
          handler: createAuthMiddleware(async (ctx) => {
            const eventContext = eventContextMap.get(ctx);
            if (!eventContext) return;

            const pathConfig = opts.pathConfig[eventContext.path];
            const shouldLogAfter = pathConfig?.logAfter !== false;
            if (!shouldLogAfter) return;

            const returned = ctx.context.returned;
            const isError = returned instanceof Error;
            const status = isError ? "failure" : "success";

            let afterState;
            if (
              (opts.captureAfterState || pathConfig?.captureState) &&
              !isError &&
              returned &&
              typeof returned === "object"
            ) {
              afterState = returned;
            }

            let metadata: any = { phase: "after" };
            if (
              isError &&
              (eventContext.path.includes("sign-in") ||
                eventContext.path.includes("signin"))
            ) {
              metadata = {
                ...metadata,
                attemptedEmail: eventContext.beforeState?.email,
                body: await redactPII(eventContext.beforeState, {
                  ...opts.piiRedaction,
                  enabled: true,
                }),
              };
            }

            const eventType = pathToEventType(eventContext.path);

            const logPromise = logEvent(ctx, {
              eventType,
              userId: eventContext.userId,
              ipAddress: eventContext.ipAddress,
              userAgent: eventContext.userAgent,
              requestId: eventContext.requestId,
              action: `${eventContext.method} ${eventContext.path}`,
              status,
              severity: pathConfig?.severity || getSeverity(eventType, status),
              beforeState: eventContext.beforeState,
              afterState,
              error: isError
                ? {
                    message: (returned as Error).message,
                    code: (returned as any).code,
                    status: (returned as any).status,
                  }
                : undefined,
              metadata,
            });

            if (!opts.nonBlocking) {
              await logPromise;
            }
          }),
        },
      ],
    },
  } satisfies BetterAuthPlugin;
};
<!-- gh-comment-id:3892081184 --> @Re4GD commented on GitHub (Feb 12, 2026): Hello, I have developed a basic audit-log plugin that i am currently testing in my own apps. It is essentially a before/after hook plugin. I have tried to make it as generic as possible such as adding retention and putting toggles for data capture etc. It is mostly finished but it would be nice to polish the API. Features: - Before/after hooks - Capture before/after bodies - Retention config for log cleanup - Data redaction PII, different redaction strategies - Non blocking mode I still don't know if there will be a first party audit logging plugin so I will be pasting the code here. I can go forward with a PR if this can evolve into that. Here are the missing pieces, and the things I am planning to add: - Query, export, analyze, clean endpoints. These would be server endpoints with a custom callback to determine if the requester has auth. `access: (ctx) => ctx.session.user.role === "admin"`, then allow these endpoints. - Client available endpoints for the one above so people can build out their admin dashboards. - Exporting/writing to S3 and other audit platforms. Currently only storage is "database". I would like to have a solid custom storage api with overridable read/write functions so people can implement their own storages. - Maybe add soc2/gdpr etc presets. But I decided not to add because it would confuse people that which toggles are flipped. - It is still not clear to me that what overriding the paths option does. I am still looking for a better whitelist/blacklist config for paths. But I am satisfied with the `string | { path: string, config: PathConfig}` API that allows the granular control for each path. - Some endpoints such as signout and delete causes descructive events, meaning that data is available on before but not after. Hence I would like to specify in the API that some paths are before only. - I am still not satisfied with the main plugin config. It could be better grouped such as `capture: { ipAddress: boolean, userAgent: boolean, beforeBody: boolean, afterBody: boolean }`. It is currently `captureIpAddress`, `captureUserAgent` etc. Same for other fields. - Normalizing actions, meaning that I am currently normalizing `/sign-in/email` to `sign-in:email`. I would like to have it normalized to `user:sign-in:email`, maybe I should add a config to `paths` something like `normalizedEvent?: string` that would convert that specific endpoint to `user:sign-in:email`. This would be better for filterability, admin events, organization events etc - Better error capturing for failed events - I still do not know how to structure the severity field. I know that for example a user delete is high, but what about other events. Also the defaults for each path is still vague to me. Even the severity field is still vague to me, I don't know how to proceed with that. - I would like to improve the additional fields types and api so people can add additional fields to this Here is the code (it may be broken in some places as I had to delete some private business logic): ``` import type { BetterAuthPlugin } from "better-auth"; import { createHash } from "@better-auth/utils/hash"; import { createRandomStringGenerator } from "@better-auth/utils/random"; import { createAuthMiddleware, getIp, getSessionFromCtx, } from "better-auth/api"; const generateId = createRandomStringGenerator("a-z", "A-Z", "0-9"); /** * Audit event record stored in database */ export interface AuditEvent { id: string; eventType: string; status: "success" | "failure" | "pending"; severity: "low" | "medium" | "high" | "critical"; userId?: string; actorUserId?: string; ipAddress?: string; userAgent?: string; resource?: string; action: string; metadata?: Record<string, any>; beforeState?: Record<string, any>; afterState?: Record<string, any>; error?: { message: string; code?: string; status?: number; }; requestId?: string; organizationId?: string; createdAt: Date; updatedAt: Date; } /** * PII redaction configuration for common fields */ export interface PIIRedactionConfig { enabled: boolean; fields?: Array< | "password" | "token" | "secret" | "apiKey" | "refreshToken" | "accessToken" | "creditCard" | "cardNumber" | "cvv" | "cvv2" | "cvc" | "expiryDate" | "cardholderName" | "pan" | "trackData" | "track1" | "track2" | "pinBlock" | "securityCode" | "cardVerificationValue" | "ssn" | "medicalRecordNumber" | "healthPlanNumber" | "accountNumber" | "certificateNumber" | "licenseNumber" | "vehicleId" | "deviceId" | "biometricId" | "email" | "phone" | "phoneNumber" | "faxNumber" | "dateOfBirth" | "address" | "postalCode" | "city" | "country" | "nationalId" | "passport" | "ipAddress" | "name" | "firstName" | "lastName" | (string & {}) >; strategy?: "mask" | "hash" | "remove"; customRedactor?: (value: any, field: string) => any; } /** * Default PII fields redacted when no fields specified */ const DEFAULT_PII_FIELDS = [ "password", "token", "secret", "apiKey", "refreshToken", "accessToken", "cardNumber", "cvv", "cvv2", "cvc", "pan", "ssn", "medicalRecordNumber", ] as const; /** * Retention policy configuration */ export interface RetentionConfig { enabled: boolean; retentionDays: number; } /** * Path-specific logging configuration */ export interface PathConfig { logBefore?: boolean; logAfter?: boolean; captureState?: boolean; severity?: "low" | "medium" | "high" | "critical"; } /** * Plugin configuration options */ export interface AuditLogOptions { enabled?: boolean; nonBlocking?: boolean; schema?: { modelName?: string; fields?: { id?: string; createdAt?: string; updatedAt?: string; eventType?: string; status?: string; severity?: string; userId?: string; actorUserId?: string; ipAddress?: string; userAgent?: string; resource?: string; action?: string; metadata?: string; beforeState?: string; afterState?: string; error?: string; requestId?: string; organizationId?: string; }; additionalFields?: Record< string, { type: "string" | "number" | "boolean" | "date"; required?: boolean; unique?: boolean; defaultValue?: any; references?: { model: string; field: string }; } >; }; piiRedaction?: PIIRedactionConfig; retention?: RetentionConfig; captureIpAddress?: boolean; captureUserAgent?: boolean; captureBeforeState?: boolean; captureAfterState?: boolean; paths?: (string | { path: string; config?: PathConfig })[]; beforeLog?: (event: AuditEvent) => Promise<AuditEvent | null>; afterLog?: (event: AuditEvent) => Promise<void>; } interface EventContext { path: string; method: string; userId?: string; ipAddress?: string; userAgent?: string; requestId?: string; organizationId?: string; beforeState?: any; } /** * Converts API path to event type * @example "/sign-in/email" to "sign-in:email" */ function pathToEventType(path: string): string { return path.replace(/^\//, "").replace(/\//g, ":"); } /** * Determines severity based on event type and status */ function getSeverity( eventType: string, status: string, ): "low" | "medium" | "high" | "critical" { if (status === "failure") return "medium"; if (eventType.includes("delete") || eventType.includes("ban")) return "critical"; if (eventType.includes("admin") || eventType.includes("role")) return "high"; if (eventType.includes("sign-in") || eventType.includes("sign-out")) return "medium"; return "low"; } /** * Redacts PII from data object according to config */ async function redactPII(data: any, config: PIIRedactionConfig): Promise<any> { if (!data || typeof data !== "object") return data; if (!config.enabled) return data; const fields = config.fields || DEFAULT_PII_FIELDS; const strategy = config.strategy || "hash"; const redacted = { ...data }; for (const field of fields) { if (field in redacted) { if (config.customRedactor) { redacted[field] = config.customRedactor(redacted[field], field); } else if (strategy === "hash") { redacted[field] = await createHash("SHA-256", redacted[field]); } else if (strategy === "mask") { redacted[field] = "***REDACTED***"; } else if (strategy === "remove") { delete redacted[field]; } } } return redacted; } /** * Creates the audit log plugin */ export const auditLog = (options: AuditLogOptions = {}) => { const opts: Required<Omit<AuditLogOptions, "pathConfig">> & { pathConfig: Record<string, PathConfig>; } = { enabled: true, nonBlocking: false, piiRedaction: { enabled: options.piiRedaction?.enabled || false, fields: options.piiRedaction?.fields || (DEFAULT_PII_FIELDS as any), strategy: options.piiRedaction?.strategy || "hash", customRedactor: options.piiRedaction?.customRedactor, }, retention: { enabled: false, retentionDays: 90 }, captureIpAddress: true, captureUserAgent: true, captureBeforeState: true, captureAfterState: true, paths: [], pathConfig: {}, beforeLog: async (event) => event, afterLog: async () => {}, ...options, schema: { modelName: "auditLog", fields: {}, additionalFields: {}, ...options.schema, }, }; const modelName = opts.schema.modelName || "auditLog"; const fieldNames = { id: opts.schema.fields?.id || "id", eventType: opts.schema.fields?.eventType || "eventType", status: opts.schema.fields?.status || "status", severity: opts.schema.fields?.severity || "severity", userId: opts.schema.fields?.userId || "userId", actorUserId: opts.schema.fields?.actorUserId || "actorUserId", ipAddress: opts.schema.fields?.ipAddress || "ipAddress", userAgent: opts.schema.fields?.userAgent || "userAgent", resource: opts.schema.fields?.resource || "resource", action: opts.schema.fields?.action || "action", metadata: opts.schema.fields?.metadata || "metadata", beforeState: opts.schema.fields?.beforeState || "beforeState", afterState: opts.schema.fields?.afterState || "afterState", error: opts.schema.fields?.error || "error", requestId: opts.schema.fields?.requestId || "requestId", organizationId: opts.schema.fields?.organizationId || "organizationId", createdAt: opts.schema.fields?.createdAt || "createdAt", updatedAt: opts.schema.fields?.updatedAt || "updatedAt", }; if (opts.paths && opts.paths.length > 0) { opts.paths.forEach((p) => { if (typeof p === "string") { opts.pathConfig[p] = { logBefore: true, logAfter: true }; } else { opts.pathConfig[p.path] = p.config || { logBefore: true, logAfter: true, }; } }); } const eventContextMap = new WeakMap<any, EventContext>(); /** * Logs an audit event to the database */ async function logEvent(ctx: any, event: Partial<AuditEvent>): Promise<void> { if (!opts.enabled) return; const now = new Date(); let completeEvent: AuditEvent = { id: generateId(32), createdAt: now, updatedAt: now, status: event.status || "success", severity: event.severity || getSeverity(event.eventType || "", event.status || "success"), eventType: event.eventType || "unknown", action: event.action || event.eventType || "unknown", ...event, } as AuditEvent; if (opts.piiRedaction.enabled) { completeEvent.metadata = await redactPII( completeEvent.metadata, opts.piiRedaction, ); completeEvent.beforeState = await redactPII( completeEvent.beforeState, opts.piiRedaction, ); completeEvent.afterState = await redactPII( completeEvent.afterState, opts.piiRedaction, ); } const modified = await opts.beforeLog(completeEvent); if (!modified) return; completeEvent = modified; if (opts.nonBlocking) { writeEvent(ctx, completeEvent).catch((err) => ctx.context.logger?.error("Non-blocking log write failed", err), ); opts .afterLog(completeEvent) .catch((err) => ctx.context.logger?.error("Non-blocking afterLog failed", err), ); } else { await writeEvent(ctx, completeEvent); await opts.afterLog(completeEvent); } } /** * Writes event to database */ async function writeEvent(ctx: any, event: AuditEvent): Promise<void> { const data: Record<string, any> = {}; data[fieldNames.id] = event.id; data[fieldNames.eventType] = event.eventType; data[fieldNames.status] = event.status; data[fieldNames.severity] = event.severity; data[fieldNames.userId] = event.userId; data[fieldNames.actorUserId] = event.actorUserId; data[fieldNames.ipAddress] = event.ipAddress; data[fieldNames.userAgent] = event.userAgent; data[fieldNames.resource] = event.resource; data[fieldNames.action] = event.action; data[fieldNames.metadata] = JSON.stringify(event.metadata || {}); data[fieldNames.beforeState] = JSON.stringify(event.beforeState || {}); data[fieldNames.afterState] = JSON.stringify(event.afterState || {}); data[fieldNames.error] = event.error ? JSON.stringify(event.error) : null; data[fieldNames.requestId] = event.requestId; data[fieldNames.organizationId] = event.organizationId; data[fieldNames.createdAt] = event.createdAt; data[fieldNames.updatedAt] = event.updatedAt; try { await ctx.context.adapter.create({ model: modelName, data, }); } catch (error) { ctx.context.logger?.error("Failed to write audit log", error); throw error; } } return { id: "auditLog", schema: { [modelName]: { fields: { [fieldNames.id]: { type: "string", required: true, unique: true }, [fieldNames.eventType]: { type: "string", required: true }, [fieldNames.status]: { type: "string", required: true }, [fieldNames.severity]: { type: "string", required: true }, [fieldNames.userId]: { type: "string", required: false, references: { model: "user", field: "id" }, }, [fieldNames.actorUserId]: { type: "string", required: false, references: { model: "user", field: "id" }, }, [fieldNames.ipAddress]: { type: "string", required: false }, [fieldNames.userAgent]: { type: "string", required: false }, [fieldNames.resource]: { type: "string", required: false }, [fieldNames.action]: { type: "string", required: false }, [fieldNames.metadata]: { type: "string", required: false }, [fieldNames.beforeState]: { type: "string", required: false }, [fieldNames.afterState]: { type: "string", required: false }, [fieldNames.error]: { type: "string", required: false }, [fieldNames.requestId]: { type: "string", required: false }, [fieldNames.organizationId]: { type: "string", required: false }, [fieldNames.createdAt]: { type: "date", required: true }, [fieldNames.updatedAt]: { type: "date", required: true }, ...opts.schema.additionalFields, }, }, }, hooks: { before: [ { matcher: (context) => { // Only log POST requests (data-modifying operations) if (context.method !== "POST") { return false; } // If no paths configured, match all POST requests if (Object.keys(opts.pathConfig).length === 0) { return true; } // Otherwise only match configured POST paths return ( context.path !== undefined && context.path in opts.pathConfig ); }, handler: createAuthMiddleware(async (ctx) => { const path = ctx.path; const method = ctx.method; let session; try { session = await getSessionFromCtx(ctx); } catch (e) { // No session } const eventContext: EventContext = { path, method, userId: session?.user?.id, requestId: generateId(16), }; if (opts.captureIpAddress && ctx.request) { const ip = getIp(ctx.request, ctx.context.options); if (ip) { eventContext.ipAddress = ip; } } if (opts.captureUserAgent) { eventContext.userAgent = ctx.headers?.get("user-agent") || undefined; } const pathConfig = opts.pathConfig[path]; if ( (opts.captureBeforeState || pathConfig?.captureState) && ctx.body ) { eventContext.beforeState = { ...ctx.body }; } eventContextMap.set(ctx, eventContext); const shouldLogBefore = pathConfig?.logBefore || path.includes("sign-out") || path.includes("signout"); if (shouldLogBefore && session) { const eventType = pathToEventType(path); const logPromise = logEvent(ctx, { eventType, userId: eventContext.userId, ipAddress: eventContext.ipAddress, userAgent: eventContext.userAgent, requestId: eventContext.requestId, action: `${method} ${path}`, status: "pending", severity: pathConfig?.severity, metadata: { phase: "before" }, }); if (!opts.nonBlocking) { await logPromise; } } return { context: ctx }; }), }, ], after: [ { matcher: (context) => { // Only log POST requests (data-modifying operations) if (context.method !== "POST") { return false; } // If no paths configured, match all POST requests if (Object.keys(opts.pathConfig).length === 0) { return true; } // Otherwise only match configured POST paths return ( context.path !== undefined && context.path in opts.pathConfig ); }, handler: createAuthMiddleware(async (ctx) => { const eventContext = eventContextMap.get(ctx); if (!eventContext) return; const pathConfig = opts.pathConfig[eventContext.path]; const shouldLogAfter = pathConfig?.logAfter !== false; if (!shouldLogAfter) return; const returned = ctx.context.returned; const isError = returned instanceof Error; const status = isError ? "failure" : "success"; let afterState; if ( (opts.captureAfterState || pathConfig?.captureState) && !isError && returned && typeof returned === "object" ) { afterState = returned; } let metadata: any = { phase: "after" }; if ( isError && (eventContext.path.includes("sign-in") || eventContext.path.includes("signin")) ) { metadata = { ...metadata, attemptedEmail: eventContext.beforeState?.email, body: await redactPII(eventContext.beforeState, { ...opts.piiRedaction, enabled: true, }), }; } const eventType = pathToEventType(eventContext.path); const logPromise = logEvent(ctx, { eventType, userId: eventContext.userId, ipAddress: eventContext.ipAddress, userAgent: eventContext.userAgent, requestId: eventContext.requestId, action: `${eventContext.method} ${eventContext.path}`, status, severity: pathConfig?.severity || getSeverity(eventType, status), beforeState: eventContext.beforeState, afterState, error: isError ? { message: (returned as Error).message, code: (returned as any).code, status: (returned as any).status, } : undefined, metadata, }); if (!opts.nonBlocking) { await logPromise; } }), }, ], }, } satisfies BetterAuthPlugin; }; ```
Author
Owner

@ejirocodes commented on GitHub (Feb 26, 2026):

Hey @lucafaggianelli, @ping-maxwell, @waldothedeveloper, @Re4GD, @FalconiZzare, @Kaseax, @josepchetrit12

I built a community plugin for this: better-auth-audit-logs

It hooks into all auth POST endpoints automatically and logs structured entries with IP, user agent, and severity inference. Also supports PII redaction (mask/hash/remove), custom storage backends (e.g. ClickHouse), configurable retention policies, and a manual insert endpoint for non-auth events.

npm install better-auth-audit-logs

Would love feedback from anyone who's been working around this gap

<!-- gh-comment-id:3968105329 --> @ejirocodes commented on GitHub (Feb 26, 2026): Hey @lucafaggianelli, @ping-maxwell, @waldothedeveloper, @Re4GD, @FalconiZzare, @Kaseax, @josepchetrit12 I built a community plugin for this: [better-auth-audit-logs](https://github.com/ejirocodes/better-auth-audit-logs) It hooks into all auth POST endpoints automatically and logs structured entries with IP, user agent, and severity inference. Also supports PII redaction (mask/hash/remove), custom storage backends (e.g. ClickHouse), configurable retention policies, and a manual insert endpoint for non-auth events. ``` npm install better-auth-audit-logs ``` Would love feedback from anyone who's been working around this gap
Author
Owner

@waldothedeveloper commented on GitHub (Feb 26, 2026):

Thank you, took a quick peek and looks great, thank you for sharing that.

<!-- gh-comment-id:3968189568 --> @waldothedeveloper commented on GitHub (Feb 26, 2026): Thank you, took a quick peek and looks great, thank you for sharing that.
Author
Owner

@Re4GD commented on GitHub (Feb 27, 2026):

Hey @ejirocodes, a brief mention in your comment would have been cool considering that almost all of your plugin APIs and functions are identical to the snippet I posted. The main plugin config, nonBlocking field, path and PathConfig types, the path normalization "/sign-in/email" to "sign-in:email" function and much more are the same. Even the improvements I added as notes are the same.

I don't mind my work being used but given how closely identical the implementation is, a simple attribution would go a long way

<!-- gh-comment-id:3974366442 --> @Re4GD commented on GitHub (Feb 27, 2026): Hey @ejirocodes, a brief mention in your comment would have been cool considering that almost all of your plugin APIs and functions are identical to the snippet I posted. The main plugin config, `nonBlocking` field, path and `PathConfig` types, the path normalization `"/sign-in/email" to "sign-in:email"` function and much more are the same. Even the improvements I added as notes are the same. I don't mind my work being used but given how closely identical the implementation is, a simple attribution would go a long way
Author
Owner

@ejirocodes commented on GitHub (Feb 27, 2026):

Hey @Re4GD,you're right, and I should have done that from the start. Your snippet was indeed a major reference point for the implementation, the config shape, path normalization approach, and nonBlocking pattern all came from your work.

I'll add attribution in the README

Appreciate you raising this directly instead of letting it slide. If you're open to collaboration I'll genuinely be glad. Thank you so much

<!-- gh-comment-id:3974433685 --> @ejirocodes commented on GitHub (Feb 27, 2026): Hey @Re4GD,you're right, and I should have done that from the start. Your snippet was indeed a major reference point for the implementation, the config shape, path normalization approach, and nonBlocking pattern all came from your work. I'll add attribution in the README Appreciate you raising this directly instead of letting it slide. If you're open to collaboration I'll genuinely be glad. Thank you so much
Author
Owner

@Re4GD commented on GitHub (Feb 27, 2026):

Hey man, no issues, I appreciate it. Any yes, I am open to collaboration. Though I am currently waiting to hear from the team to confirm whether someone is already working on an official audit logging plugin. I know they are building https://www.better-auth.build currently and the product surely has audit logging, but will they make the audit logging plugin part open source, that I don't know. Once that's cleared up, we can go from there

@Bekacru @ping-maxwell @himself65 is anyone working on audit logging plugin from the team? And also considering the infra project, will you guys open source the audit plugin part (and potentially other parts)? We would like to go forward with a PR if an official plugin is not being worked on or an existing one (infra) won't be accessible in the near future

<!-- gh-comment-id:3974524695 --> @Re4GD commented on GitHub (Feb 27, 2026): Hey man, no issues, I appreciate it. Any yes, I am open to collaboration. Though I am currently waiting to hear from the team to confirm whether someone is already working on an official audit logging plugin. I know they are building https://www.better-auth.build currently and the product surely has audit logging, but will they make the audit logging plugin part open source, that I don't know. Once that's cleared up, we can go from there @Bekacru @ping-maxwell @himself65 is anyone working on audit logging plugin from the team? And also considering the infra project, will you guys open source the audit plugin part (and potentially other parts)? We would like to go forward with a PR if an official plugin is not being worked on or an existing one (infra) won't be accessible in the near future
Author
Owner

@ejirocodes commented on GitHub (Feb 28, 2026):

Yeah, one can be sure they will some sort of audit and activity log built into the infrastructure.

PS: @Re4GD, I've updated the readme to include attribution from the community

<!-- gh-comment-id:3976090413 --> @ejirocodes commented on GitHub (Feb 28, 2026): Yeah, one can be sure they will some sort of audit and activity log built into the infrastructure. PS: @Re4GD, I've updated the [readme](https://github.com/ejirocodes/better-auth-audit-logs#acknowledgments) to include attribution from the community
Author
Owner

@Daymannovaes commented on GitHub (Mar 3, 2026):

One thing I'd add to what @Re4GD and @ejirocodes have built: tamper evidence.

For compliance frameworks like SOC2 and HIPAA, it's not enough to have logs, but you need to prove they haven't been modified.

A simple approach: include a SHA-256 hash chain where each entry hashes the previous entry's hash + its own content. If any record is altered or deleted, the chain breaks. It would needed for audition because it's verifiable without trusting the storage layer.

Great work on the implementations so far.

<!-- gh-comment-id:3990351550 --> @Daymannovaes commented on GitHub (Mar 3, 2026): One thing I'd add to what @Re4GD and @ejirocodes have built: tamper evidence. For compliance frameworks like SOC2 and HIPAA, it's not enough to have logs, but you need to prove they haven't been modified. A simple approach: include a SHA-256 hash chain where each entry hashes the previous entry's hash + its own content. If any record is altered or deleted, the chain breaks. It would needed for audition because it's verifiable without trusting the storage layer. Great work on the implementations so far.
Author
Owner

@ejirocodes commented on GitHub (Mar 3, 2026):

Hi @Daymannovaes, this is definitely a great idea. Would you be adding this as an issue here

<!-- gh-comment-id:3990750105 --> @ejirocodes commented on GitHub (Mar 3, 2026): Hi @Daymannovaes, this is definitely a great idea. Would you be adding this as an issue [here](https://github.com/ejirocodes/better-auth-audit-logs/issues/new)
Author
Owner

@Daymannovaes commented on GitHub (Mar 3, 2026):

sure! Just did: https://github.com/ejirocodes/better-auth-audit-logs/issues/14

<!-- gh-comment-id:3991064485 --> @Daymannovaes commented on GitHub (Mar 3, 2026): sure! Just did: https://github.com/ejirocodes/better-auth-audit-logs/issues/14
Author
Owner

@Re4GD commented on GitHub (Mar 3, 2026):

If two audit events are written concurrently, both might read the previous hash and break the entire chain. We would need serialized writes either with a queue or db transactions, and with nonBlocking disabled this might hurt the response times even more. Maybe it can be an explicitly enabled optional feature with default false. And which fields should be included in the hash

<!-- gh-comment-id:3992585565 --> @Re4GD commented on GitHub (Mar 3, 2026): If two audit events are written concurrently, both might read the previous hash and break the entire chain. We would need serialized writes either with a queue or db transactions, and with nonBlocking disabled this might hurt the response times even more. Maybe it can be an explicitly enabled optional feature with default false. And which fields should be included in the hash
Author
Owner

@Re4GD commented on GitHub (Mar 4, 2026):

https://beta.better-auth.com/docs/infrastructure/plugins/audit-logs they have added it in the dashboard plugin and https://www.npmjs.com/package/@better-auth/dash?activeTab=code the source can be seen from the output. I guess they are not releasing this as open source

<!-- gh-comment-id:4000989533 --> @Re4GD commented on GitHub (Mar 4, 2026): https://beta.better-auth.com/docs/infrastructure/plugins/audit-logs they have added it in the dashboard plugin and https://www.npmjs.com/package/@better-auth/dash?activeTab=code the source can be seen from the output. I guess they are not releasing this as open source
Author
Owner

@ping-maxwell commented on GitHub (Mar 25, 2026):

Hello all, there are multiple alternatives for this now. Better Auth Infrastructure supports audit logging, and there are a few community solutions for this too. I'll be closing this.

<!-- gh-comment-id:4124703690 --> @ping-maxwell commented on GitHub (Mar 25, 2026): Hello all, there are multiple alternatives for this now. Better Auth Infrastructure supports audit logging, and there are a few community solutions for this too. I'll be closing this.
Author
Owner

@github-actions[bot] commented on GitHub (Apr 2, 2026):

This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.

<!-- gh-comment-id:4173719337 --> @github-actions[bot] commented on GitHub (Apr 2, 2026): This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#8632