Skip to content

ejirocodes/better-auth-audit-logs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

Audit log plugin for Better Auth. Auto-captures auth events with severity inference, PII redaction, custom storage backends, and retention policies.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors