MCPcopy Index your code
hub / github.com/ejirocodes/better-auth-audit-logs

github.com/ejirocodes/better-auth-audit-logs @v0.3.0

Chat with this repo
repository ↗ · DeepWiki ↗ · release v0.3.0 ↗ · + Follow
69 symbols 177 edges 40 files 0 documented · 0%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

better-auth-audit-logs

npm version npm downloads license

Audit log plugin for Better Auth. Automatically captures auth events with IP, user agent, and severity — zero config required.

Requires better-auth >= 1.0.0 and typescript >= 5.

Quick start

npm install better-auth-audit-logs
import { betterAuth } from "better-auth";
import { auditLog } from "better-auth-audit-logs";

export const auth = betterAuth({
  plugins: [auditLog()],
});

Then generate and run the migration:

npx @better-auth/cli generate

That's it. All auth events are now logged automatically.

Schema

The plugin adds an auditLog table. If you prefer to manage your schema manually, copy the relevant definition:

Prisma

model AuditLog {
  id        String   @id @default(cuid())
  userId    String?
  action    String
  status    String
  severity  String
  ipAddress String?
  userAgent String?
  metadata  String?
  createdAt DateTime @default(now())

  user User? @relation(fields: [userId], references: [id], onDelete: SetNull)

  @@index([userId])
  @@index([action])
  @@index([createdAt])
  @@map("auditLog")
}

Drizzle

import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { user } from "./auth-schema"; // your existing user table

export const auditLog = sqliteTable("auditLog", {
  id: text("id").primaryKey(),
  userId: text("userId").references(() => user.id, { onDelete: "set null" }),
  action: text("action").notNull(),
  status: text("status").notNull(),
  severity: text("severity").notNull(),
  ipAddress: text("ipAddress"),
  userAgent: text("userAgent"),
  metadata: text("metadata"),
  createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});

MongoDB

// Collection: auditLog
{
  _id: ObjectId,
  userId: String | null,       // references user collection
  action: String,              // e.g. "sign-in:email"
  status: String,              // "success" | "failed"
  severity: String,            // "low" | "medium" | "high" | "critical"
  ipAddress: String | null,
  userAgent: String | null,
  metadata: String | null,     // JSON string
  createdAt: Date
}

// Recommended indexes
db.auditLog.createIndex({ userId: 1 })
db.auditLog.createIndex({ action: 1 })
db.auditLog.createIndex({ createdAt: 1 })

Client plugin

import { createAuthClient } from "better-auth/client";
import { auditLogClient } from "better-auth-audit-logs/client";

export const authClient = createAuthClient({
  plugins: [auditLogClient()],
});
// List recent failed sign-ins
const { data } = await authClient.auditLog.listAuditLogs({
  query: { status: "failed", limit: 20 },
});

// Single entry by ID
const { data: entry } = await authClient.auditLog.getAuditLog({
  params: { id: "log-entry-id" },
});

// Manually log custom events (admin actions, data exports, etc.)
await authClient.auditLog.insertAuditLog({
  action: "admin:user-export",
  status: "success",
  severity: "high",
  metadata: { exportedCount: 500 },
});

What gets logged

All auth POST endpoints are captured by default:

Event Path Hook
Sign in /sign-in/email, /sign-in/social after
Sign up /sign-up/email after
Change/reset password /change-password, /reset-password after
Change email /change-email after
Two-factor /two-factor/* after
OAuth callback /oauth/callback after
Sign out /sign-out before
Delete account /delete-user before
Revoke session /revoke-session, /revoke-sessions, /revoke-other-sessions before

"Before" hooks fire for destructive events where the session would be lost after execution.

Severity is inferred automatically (critical for ban/impersonate, high for delete/revoke/failed sign-in, medium for sign-in/out, low for everything else) and can be overridden per-path.

Configuration

All options are optional:

auditLog({
  enabled: true,             // disable without removing the plugin
  nonBlocking: false,        // fire-and-forget — never blocks auth responses

  // restrict to specific paths (empty = capture all)
  paths: [
    "/sign-in/email",
    { path: "/delete-user", config: { severity: "high", capture: { requestBody: true } } },
  ],

  capture: {
    ipAddress: true,         // capture client IP
    userAgent: true,         // capture User-Agent header
    requestBody: false,      // include request body in metadata
  },

  piiRedaction: {
    enabled: false,          // redact sensitive fields when requestBody is captured
    strategy: "mask",        // "mask" (***) | "hash" (SHA-256) | "remove" (delete key)
    fields: ["password"],    // defaults: password, token, secret, apiKey, otp, etc.
  },

  retention: {
    enabled: false,          // enable scheduled cleanup
    days: 90,                // delete entries older than N days
  },

  // intercept before write — return null to suppress
  beforeLog: async (entry) => {
    if (entry.userId === "service-account") return null;
    return entry;
  },

  // called after each successful write
  afterLog: async (entry) => {
    await analytics.track("auth.event", entry);
  },

  storage: undefined,        // custom storage backend (see below)
})

To override the DB model name, pass schema: { auditLog: { modelName: "your_table_name" } }.

Custom storage

Route writes to any external backend instead of Better Auth's database:

import { auditLog, type AuditLogStorage } from "better-auth-audit-logs";

const clickhouse: AuditLogStorage = {
  async write(entry) {
    await fetch("https://ch.example.com/insert", {
      method: "POST",
      body: JSON.stringify(entry),
    });
  },
  // Optional — enables the query endpoints to work with your backend
  async read(options) { /* ... */ },
  async readById(id) { /* ... */ },
};

auditLog({ storage: clickhouse })

A MemoryStorage adapter is included for testing:

import { auditLog, MemoryStorage } from "better-auth-audit-logs";

const storage = new MemoryStorage();
const auth = betterAuth({ plugins: [auditLog({ storage })] });

// assert in tests
expect(storage.entries).toHaveLength(1);
expect(storage.entries[0].action).toBe("sign-in:email");

API endpoints

Three endpoints are registered under /audit-log/, all requiring an active session. Rate limited to 60 req/min.

Endpoint Method Description
/audit-log/list GET Paginated entries
/audit-log/:id GET Single entry by ID
/audit-log/insert POST Manually insert a custom event

Query parameters for GET /audit-log/list:

Parameter Type Default
userId string session user
action string
status "success" \| "failed"
from ISO date string
to ISO date string
limit number 50 (max 500)
offset number 0

Design decisions

  • Entries survive user deletionuserId uses ON DELETE SET NULL. Deleting a user does not erase their audit trail.
  • userAgent is not returned in API responses — stored for forensics but excluded from client queries by default.
  • Failed sign-ins have userId: null — the user isn't authenticated yet, so there's no session to pull from.

Recommended production config

auditLog({
  nonBlocking: true,
  piiRedaction: { enabled: true, strategy: "hash" },
  retention: { enabled: true, days: 90 },
  afterLog: async (entry) => {
    if (entry.severity === "critical" || entry.severity === "high") {
      await alerting.emit(entry);
    }
  },
})

Acknowledgments

This plugin was inspired by the audit log design shared by @Re4GD in better-auth/better-auth#1184. Additional inspiration from @issamwahbi (#3592) and @ItsProless (#7952).

License

MIT

Extension points exported contracts — how you extend this code

AuditLogStorage (Interface)
(no doc) [3 implementers]
src/types.ts
BuildParams (Interface)
(no doc)
src/internal.ts
MetadataLimits (Interface)
(no doc)
src/utils/validate-metadata.ts
MemoryStorageOptions (Interface)
(no doc)
src/adapters/memory.ts
AuditLogEntry (Interface)
(no doc)
src/types.ts
StorageReadOptions (Interface)
(no doc)
src/types.ts
StorageReadResult (Interface)
(no doc)
src/types.ts
PIIRedactionOptions (Interface)
(no doc)
src/types.ts

Core symbols most depended-on inside this repo

auditLog
called by 29
src/plugin.ts
writeEntry
called by 26
src/internal.ts
write
called by 24
src/types.ts
parseMetadata
called by 18
src/utils/parse-metadata.ts
redactPII
called by 14
src/utils/sanitize.ts
validateEntry
called by 12
src/utils/validate-entry.ts
inferSeverity
called by 12
src/utils/severity.ts
read
called by 9
src/types.ts

Shape

Function 38
Interface 15
Method 12
Class 4

Languages

TypeScript100%

Modules by API surface

src/types.ts16 symbols
src/adapters/memory.ts8 symbols
tests/custom-adapter.test.ts5 symbols
src/internal.ts5 symbols
tests/internal.test.ts3 symbols
tests/error-scenarios.test.ts3 symbols
src/utils/validate-metadata.ts3 symbols
src/schema.ts3 symbols
src/plugin.ts3 symbols
tests/pii-integration.test.ts2 symbols
src/utils/sanitize.ts2 symbols
tests/utils/validate-entry.test.ts1 symbols

For agents

$ claude mcp add better-auth-audit-logs \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact

Ask about this repo answers extend the page