Audit logs are how you answer “who changed what, when, and why?” — required for SOC 2, HIPAA, financial regulations, and security incident response. The challenge is making them immutable (attackers can’t erase their tracks), complete (nothing slips through), and queryable (you can actually find what happened). Claude Code generates audit logging middleware, database triggers for automatic change capture, and the tamper-evident log structures compliance requires.
Audit Log Architecture
Build an audit logging system that captures:
- All data modifications (who, what, before/after values, when)
- All authentication events (login, logout, failed attempts)
- All admin actions (privilege escalation, user management)
Must be tamper-evident and not depend on application code to write it.
Database Trigger Approach (Automatic, Can’t Be Bypassed)
-- audit_log table: append-only (no UPDATE/DELETE allowed via policy)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
user_id TEXT, -- From application session
ip_address INET,
old_values JSONB,
new_values JSONB,
changed_by TEXT NOT NULL DEFAULT current_user, -- DB user (service account)
checksum TEXT -- HMAC of row content for tamper detection
);
-- Prevent deletes and updates to audit_log itself
CREATE RULE audit_log_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING;
CREATE RULE audit_log_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING;
-- Function that runs on trigger
CREATE OR REPLACE FUNCTION log_changes() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (
table_name, record_id, operation,
user_id, ip_address, old_values, new_values
) VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id::TEXT, OLD.id::TEXT),
TG_OP,
current_setting('app.user_id', true), -- Set by application middleware
current_setting('app.ip_address', true)::INET,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END,
CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Attach to tables that need auditing
CREATE TRIGGER audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION log_changes();
CREATE TRIGGER audit_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION log_changes();
The trigger captures changes regardless of whether the application code logs them — even direct SQL modifications by DBAs are recorded.
Application Context Middleware
The trigger reads app.user_id from session settings — the application must set this:
// middleware/auditContext.ts
export function auditContextMiddleware(req: Request, res: Response, next: NextFunction) {
// All DB operations in this request will have user context
db.raw(`
SELECT set_config('app.user_id', ?, true),
set_config('app.ip_address', ?, true)
`, [req.user?.userId ?? 'anonymous', req.ip]).then(() => next());
}
app.use(authenticate);
app.use(auditContextMiddleware);
Application-Level Audit Events
For events that aren’t database changes (logins, exports, permission changes):
// services/AuditService.ts
import { createHmac } from 'crypto';
interface AuditEvent {
userId: string;
action: string;
resourceType?: string;
resourceId?: string;
metadata?: Record<string, unknown>;
ipAddress: string;
userAgent?: string;
success: boolean;
failureReason?: string;
}
export class AuditService {
private readonly HMAC_KEY = process.env.AUDIT_HMAC_KEY!; // For tamper detection
async log(event: AuditEvent): Promise<void> {
const entry = {
...event,
occurred_at: new Date(),
seq: await this.nextSequenceNumber(),
};
// Compute HMAC over content + previous entry's HMAC (hash chain)
const prevChecksum = await this.getLastChecksum();
const content = JSON.stringify(entry) + prevChecksum;
const checksum = createHmac('sha256', this.HMAC_KEY).update(content).digest('hex');
await db('application_audit_log').insert({ ...entry, checksum });
}
// Verify log integrity — detect if any entries were modified or deleted
async verifyIntegrity(fromId?: number): Promise<{ valid: boolean; firstTamperedId?: number }> {
const entries = await db('application_audit_log')
.where('id', '>=', fromId ?? 0)
.orderBy('id', 'asc');
let prevChecksum = '';
for (const entry of entries) {
const { checksum, ...rest } = entry;
const content = JSON.stringify(rest) + prevChecksum;
const expected = createHmac('sha256', this.HMAC_KEY).update(content).digest('hex');
if (expected !== checksum) {
return { valid: false, firstTamperedId: entry.id };
}
prevChecksum = checksum;
}
return { valid: true };
}
private async nextSequenceNumber(): Promise<number> {
const result = await db.raw('SELECT nextval(\'audit_log_seq\')');
return result.rows[0].nextval;
}
private async getLastChecksum(): Promise<string> {
const last = await db('application_audit_log').orderBy('id', 'desc').first('checksum');
return last?.checksum ?? '';
}
}
Authentication Audit Trail
// middleware/authAudit.ts — log all auth events
export async function logAuthEvent(
type: 'login' | 'logout' | 'login_failed' | 'password_reset' | 'mfa_failed',
userId: string | null,
req: Request,
extra?: Record<string, unknown>,
) {
await audit.log({
userId: userId ?? 'anonymous',
action: type,
resourceType: 'auth',
metadata: {
...extra,
userAgent: req.headers['user-agent'],
},
ipAddress: req.ip,
success: !type.includes('failed'),
failureReason: type.includes('failed') ? type : undefined,
});
}
// In your login handler:
app.post('/auth/login', async (req, res) => {
try {
const user = await authenticate(req.body.email, req.body.password);
await logAuthEvent('login', user.id, req);
// ...
} catch (err) {
await logAuthEvent('login_failed', null, req, { email: req.body.email });
res.status(401).json({ error: 'Invalid credentials' });
}
});
Audit Log Query API
// api/audit/query.ts — allow admins to search the audit trail
export async function GET(request: Request) {
const session = await requireAdminSession(request);
const { searchParams } = new URL(request.url);
const query = db('audit_log')
.select('*')
.orderBy('occurred_at', 'desc');
if (searchParams.get('userId')) {
query.where('user_id', searchParams.get('userId'));
}
if (searchParams.get('resourceId')) {
query.where('record_id', searchParams.get('resourceId'));
}
if (searchParams.get('from')) {
query.where('occurred_at', '>=', new Date(searchParams.get('from')!));
}
if (searchParams.get('to')) {
query.where('occurred_at', '<=', new Date(searchParams.get('to')!));
}
const page = parseInt(searchParams.get('page') ?? '1');
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50'), 200);
const [events, [{ count }]] = await Promise.all([
query.clone().limit(limit).offset((page - 1) * limit),
query.clone().count('* as count'),
]);
return Response.json({ events, total: Number(count), page, limit });
}
CLAUDE.md for Compliance Requirements
## Audit Logging Requirements (SOC 2 Type II)
- All PII modifications: logged via DB triggers (automatic)
- Authentication events: logged via authAudit middleware
- Admin actions: must call AuditService.log() explicitly
- Log retention: 7 years (HIPAA) / 3 years (SOC 2) — enforced by lifecycle policy
- Export: audit logs available via admin API /audit/export?from=&to= (date range)
- Integrity verification: run weekly via scheduled job, alert on failure
- Never log passwords, tokens, or credit card numbers in audit fields
- Before/after diffs: DB triggers capture these automatically
For the security testing that validates your audit trail catches all attack vectors, see the security testing guide. For the observability layer that alerts on suspicious audit patterns, see the OpenTelemetry guide. The Claude Skills 360 bundle includes compliance skill sets for SOC 2, HIPAA, and tamper-evident audit logging. Start with the free tier to try audit system scaffolding.