The Complete Guide to Audit Log Schema Design
How to design an audit log schema that satisfies SOC 2 auditors, scales to millions of events, and actually helps your customers. Includes a field-by-field breakdown, TypeScript examples, and the mistakes we see teams make.
1. Why audit log schema matters
Every B2B SaaS app eventually needs audit logs. An enterprise buyer asks "can we see who did what?" and suddenly you're designing a schema from scratch under deadline pressure.
The schema you choose determines everything downstream: whether your SOC 2 auditor is satisfied, whether your search queries are fast, and whether your customers can actually use the logs you provide.
Get it wrong and you'll be migrating data, re-indexing tables, and apologizing to auditors. Get it right and audit logging becomes a competitive advantage — the feature that closes enterprise deals.
2. The anatomy of an audit event
An audit event answers five questions:
- Who performed the action? (the actor)
- What did they do? (the action)
- What was affected? (the resource/target)
- When did it happen? (the timestamp)
- What was the result? (success or failure)
This is the "5W" model. Every field in your schema should map to one of these questions. If a field doesn't answer one of these, it belongs in metadata — not at the top level.
3. Required fields (the non-negotiables)
These fields must be present on every single audit event. If any of these are missing, your audit trail has gaps that will concern auditors.
| Field | Type | Example | Why Required |
|---|---|---|---|
| event_id | UUID v7 | "01902a3b-..." | Unique identifier; UUIDv7 gives time-ordering for free |
| timestamp | ISO 8601 | "2026-03-05T14:30:00Z" | When the event occurred. Always store in UTC. |
| actor.id | string | "user_abc123" | Who performed the action. Must be traceable to a person. |
| actor.email | string | "alice@co.com" | Human-readable actor identifier. Auditors need names, not just IDs. |
| action | string | "document.delete" | Verb describing what happened. Use resource.verb format. |
| resource.type | string | "document" | What type of thing was affected. |
| resource.id | string | "doc_456" | Which specific instance was affected. |
| outcome | enum | "SUCCESS" | "FAILURE" | "DENIED" | Did the action succeed? Critical for security monitoring. |
4. Recommended fields (what auditors love)
These fields aren't strictly required but dramatically improve your audit trail's usefulness for security investigations and compliance reviews.
| Field | Type | Purpose |
|---|---|---|
| source_ip | string | IP address of the request. Essential for security investigations. |
| user_agent | string | Browser/client info. Helps detect unauthorized access patterns. |
| request_id | UUID | Correlates audit events with application logs for debugging. |
| tenant_id | string | Multi-tenant isolation. Each customer sees only their own logs. |
| metadata | JSON object | Flexible key-value pairs for action-specific context (old values, new values, file names, etc.) |
| geo_location | string | Country/region derived from IP. Useful for impossible-travel detection. |
| session_id | string | Links multiple actions to a single user session. |
| previous_hash | string | SHA-256 hash of the previous event. Creates a tamper-evident chain. |
5. The complete schema
Here's a TypeScript interface that combines the required and recommended fields into a production-ready audit event schema:
interface AuditEvent {
// Required fields
event_id: string; // UUIDv7 — time-ordered unique ID
timestamp: string; // ISO 8601 in UTC
actor: {
id: string; // Internal user ID
email: string; // Human-readable identifier
type?: string; // "user" | "api_key" | "system"
};
action: string; // "resource.verb" format
resource: {
type: string; // "document", "user", "project"
id: string; // Specific instance ID
name?: string; // Human-readable name (optional)
};
outcome: "SUCCESS" | "FAILURE" | "DENIED";
// Recommended fields
tenant_id: string; // Multi-tenant isolation
source_ip?: string; // Client IP address
user_agent?: string; // Browser/client info
request_id?: string; // Correlation ID
metadata?: Record<string, unknown>; // Action-specific context
previous_hash?: string; // SHA-256 hash chain link
}And here's how you'd log an event using the Trailbase SDK:
import { TrailbaseClient } from '@trailbase/sdk';
const trailbase = new TrailbaseClient({
apiKey: process.env.TRAILBASE_API_KEY,
});
await trailbase.log({
action: 'document.delete',
actor: { id: 'user_123', email: 'alice@company.com' },
resource: { type: 'document', id: 'doc_456' },
outcome: 'SUCCESS',
metadata: {
filename: 'quarterly-report.pdf',
size_bytes: 2_450_000,
deleted_by_admin: false,
},
});6. Tamper-proofing with hash chains
A SHA-256 hash chain links each event to the previous one. If any event is modified or deleted, the chain breaks — providing cryptographic evidence of tampering.
Event 1: hash = SHA-256(event_data) Event 2: hash = SHA-256(event_data + event_1_hash) Event 3: hash = SHA-256(event_data + event_2_hash) ... // To verify integrity: // Recompute each hash and compare. // If any hash doesn't match → tampering detected.
This is the same principle behind blockchain, but without the consensus overhead. Trailbase computes hash chains automatically — every event includes a previous_hash field that you can independently verify.
For a deeper dive, see our post: SHA-256 Hash Chains Explained.
7. Indexing for query performance
Audit logs are write-heavy but read-critical when an incident occurs. Your indexing strategy should optimize for the most common query patterns:
| Query Pattern | Recommended Index |
|---|---|
| All events for a tenant (paginated) | (tenant_id, timestamp DESC) |
| Events by a specific actor | (tenant_id, actor_id, timestamp DESC) |
| Events on a specific resource | (tenant_id, resource_type, resource_id, timestamp DESC) |
| Events by action type | (tenant_id, action, timestamp DESC) |
| Correlation with app logs | (tenant_id, request_id) |
Always include tenant_id as the first column in every index. This ensures query isolation in multi-tenant architectures and prevents full table scans.
8. Common mistakes
After reviewing hundreds of audit log implementations, here are the most common mistakes we see:
1. Using auto-increment IDs
Sequential IDs leak information (how many events you have, your growth rate). Use UUIDv7 instead — time-ordered but opaque.
2. Storing timestamps without timezone
Always store timestamps in UTC with explicit timezone offset. "2026-03-05T14:30:00Z" not "2026-03-05 14:30:00". Ambiguous timestamps are useless in cross-timezone investigations.
3. Logging only successful actions
Failed and denied actions are often more important than successful ones. A denied "admin.settings.update" attempt is a security signal. Always log the outcome.
4. Making audit logs mutable
If someone can UPDATE or DELETE audit log rows, the entire trail is suspect. Use append-only tables, hash chains, or a dedicated audit log service.
5. Treating audit logs like application logs
Application logs (console.log, debug output) are for developers. Audit logs are for compliance, security, and customers. Different audiences, different schemas, different retention policies.
6. No tenant isolation in multi-tenant apps
Every query must be scoped to a tenant_id. Without this, a bug or SQL injection could expose one customer's audit trail to another.
9. Audit logs vs application logs
| Audit Logs | Application Logs | |
|---|---|---|
| Audience | Auditors, security, customers | Developers, SREs |
| Data model | Structured (actor, action, resource) | Unstructured text or JSON |
| Mutability | Immutable (append-only) | Mutable (can be rotated, deleted) |
| Retention | 1-10 years (compliance-driven) | Days to weeks |
| Query pattern | By actor, resource, time range | Full-text search, grep |
| Example | "Alice deleted document X" | "Error: connection timeout on port 5432" |
| Compliance | Required for SOC 2, HIPAA, GDPR | Not typically audited |
10. Retention strategies
Different compliance frameworks mandate different retention periods:
| Framework | Minimum Retention | Notes |
|---|---|---|
| SOC 2 | 1 year | Type II audits review the past 12 months of evidence |
| HIPAA | 6 years | Required for all security-relevant audit records |
| GDPR | No fixed period | Must have a lawful basis; delete when no longer needed |
| ISO 27001 | 3 years (recommended) | Aligned with certification cycle |
| PCI DSS | 1 year | 3 months must be immediately available for analysis |
Use tiered storage to manage costs: hot storage (fast queries, recent data), warm storage (slower queries, older data), and cold storage (archival, compliance-only access). Trailbase handles this automatically with configurable retention tiers.
FAQ
What fields should an audit log event contain?
At minimum: event ID, timestamp (ISO 8601 UTC), actor (id + email), action verb, resource (type + id), and outcome (success/failure). Add source IP, user agent, request ID, and metadata for better auditability.
How do you make audit logs tamper-proof?
Use SHA-256 hash chains where each event includes a hash of the previous event. Store logs in append-only storage. Encrypt sensitive fields with AES-256-GCM. Trailbase does all of this automatically.
What is the difference between application logs and audit logs?
Application logs are for developers (errors, debugging). Audit logs are for compliance and customers (who did what, when). Different audiences, different schemas, different retention.
How long should you retain audit logs?
Depends on compliance: SOC 2 requires 1 year, HIPAA requires 6 years. Most B2B SaaS companies retain 1-3 years with tiered storage to manage costs.
Skip the schema design. Use Trailbase.
Trailbase handles schema design, hash chains, indexing, retention, and compliance reporting. Send your first audit event in 5 minutes.
Get Started FreeView Pricing