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.
npm install better-auth-audit-logsimport { 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 generateThat's it. All auth events are now logged automatically.
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 })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 },
});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.
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" } }.
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");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 |
- Entries survive user deletion —
userIdusesON DELETE SET NULL. Deleting a user does not erase their audit trail. userAgentis 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.
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);
}
},
})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).