Audit Logs for Authentication: What to Record Before You Need It
The events every auth system should log, the schema that makes them queryable, why application logs are not an audit trail, tamper-evidence and retention, and how to expose logs to customers without leaking other tenants' data.
There is a specific conversation that happens after every account compromise. A customer says their data was accessed by someone else. You go looking. And you discover that your logs can tell you a request returned 200 at 14:32:07, but not who was authenticated, from where, using which credential, or what changed as a result.
Audit logging is unglamorous infrastructure that produces no value until the day it produces enormous value. It is also nearly impossible to add retroactively, because the events you need are the ones that already happened.
This article covers what to record, how to structure it, and the distinctions that separate an audit trail from a pile of log lines.
Application logs are not an audit trail
They serve different purposes and have incompatible requirements. Conflating them is the most common mistake.
| Application logs | Audit logs | |
|---|---|---|
| Audience | Engineers debugging | Security, compliance, customers |
| Content | Whatever helps right now | A fixed, documented schema |
| Retention | Days to weeks | Months to years |
| Mutability | Rotated, dropped, sampled | Append-only, tamper-evident |
| Sampling | Normal under load | Never |
| Schema stability | Changes freely | Contractual |
That sampling row is the one that bites. Under load — precisely when an attack is happening — most logging pipelines start dropping lines. An audit trail with gaps during the incident is not an audit trail.
The console.log in your login handler is not an audit event. An audit event is a deliberate, structured record written on a path that cannot be sampled away.
What to record
The rule: every event that changes who can access what, and every attempt to.
Authentication
- Sign-in succeeded — with method (password, OAuth provider, magic link, passkey, SSO)
- Sign-in failed — with a reason category, never the attempted credential
- MFA challenged, satisfied, failed
- Step-up authentication required and satisfied
- Sign-out, and sign-out-everywhere
- Session created, refreshed, expired, revoked
- Refresh token reuse detected — one of the highest-value events you can record
Credentials
- Password changed, reset requested, reset completed
- MFA factor enrolled or removed
- Passkey registered or deleted
- API key created, rotated, revoked — with the key's prefix, never the key
- Recovery codes generated or consumed
Identity
- Account created, email changed, email verified
- Identity provider linked or unlinked
- Account suspended, reactivated, deleted
Authorization and tenancy
- Role or permission changed — with the before and after values
- Member invited, invitation accepted, member removed
- Organization created, settings changed, SSO configuration changed
- Tenant switched
Administrative
- Any impersonation, start and end, with both identities
- Bulk operations
- Configuration changes to security settings
Two specific things deserve emphasis because they are so often missing. Before and after values on permission changes — "role changed" without the old and new value is nearly useless six months later. And the sign-in failed reason as a category (bad_password, unknown_user, account_locked, mfa_failed), never the submitted password, which is how plaintext credentials end up permanently in log storage.
The schema
A flat, boring, stable structure beats a clever one. Each event answers: who, what, to what, when, from where, and did it work.
{
"id": "evt_01J8X2K9",
"occurred_at": "2026-08-02T14:32:07.412Z",
"organization_id": "org_acme",
"actor": {
"type": "user",
"id": "user_123",
"email_hash": "sha256:9f2a…",
"impersonated_by": null
},
"action": "auth.session.created",
"target": { "type": "session", "id": "sess_456" },
"outcome": "success",
"context": {
"ip": "203.0.113.9",
"user_agent": "Mozilla/5.0 …",
"geo": { "country": "DE", "city": "Berlin" },
"request_id": "req_789"
},
"metadata": { "method": "password", "mfa": "totp" }
}
Design notes that matter more than they look:
Namespace the actions hierarchically. auth.session.created, auth.password.changed, org.member.role_changed. This lets you query by prefix and gives you an obvious place to add new events without reorganizing.
actor.type is an enum, not always user. Service accounts, API keys, system jobs, and support staff all take actions. A log that assumes a human actor cannot represent "the nightly job deactivated 40 accounts."
impersonated_by is a first-class field. If a support engineer is acting as a customer, both identities must be in the record. An impersonated action logged as the customer is a falsified record.
request_id ties the audit event back to your application logs. This is the single most useful field during an investigation — it is the bridge between the audit trail and the debugging detail.
organization_id is mandatory in multi-tenant systems, because it is the filter that makes customer-facing logs possible. Retrofitting it is painful.
Hash the email rather than storing it, or store it and accept the deletion obligation. Which brings us to the hard part.
Privacy, and the deletion problem
An audit log is a data store full of personal data — IP addresses, user agents, geolocation, identifiers — and it collides directly with data protection obligations. Two conflicts, both real.
The right to erasure versus tamper-evidence. A user requests deletion. Your audit log is append-only by design. These cannot both be fully satisfied.
The workable resolution is crypto-shredding: store the identifying fields encrypted with a per-subject key, and delete the key on erasure. The record remains — its existence, its timing, its action, its causal position in the chain — while the personal data becomes unrecoverable. The integrity of the log survives; the personal data does not.
The alternative is pseudonymization from the start: the log holds only internal identifiers, and the mapping from identifier to person lives in a separate, deletable store. Simpler, and it means your audit log is far less useful to read directly.
Either way, have the answer written down before someone asks, because "we cannot delete it, it is an audit log" is not a defence.
Retention. Longer is better for investigation and worse for privacy and cost. Common landing points are 90 days hot and queryable, one year in cheap storage, and longer only where a specific regulation requires it. Whatever you choose, make it explicit, enforce it automatically, and document it — an undocumented "we keep everything forever" is a liability with no owner.
Tamper-evidence
If an attacker gains sufficient access, the first thing they do is edit the logs. Making tampering detectable is achievable without much complexity.
Hash chaining. Each entry includes a hash of the previous entry, so any modification breaks the chain from that point forward:
entry_n.prev_hash = SHA256(canonical_json(entry_n-1))
Publish the head hash somewhere the application cannot reach — a separate account, a different provider, or an external timestamping service — periodically. That external anchor is what turns "we can detect edits" into "we can prove the log is intact."
Write-once storage. Object storage with an immutability or legal-hold policy, or a database with append-only permissions for the application role. The credential your application uses to write audit events should have no ability to update or delete them.
Separate the store. If audit logs live in the same database, with the same credentials, as everything else, then a full compromise takes both. A separate store with a different trust boundary is the meaningful control.
None of this prevents tampering by someone with sufficient infrastructure access. It makes it detectable, which is what the word evidence means.
Making them useful
An audit log that cannot be queried during an incident is a compliance artifact, not a tool. The queries you will actually run:
- Everything for user X in a time window
- Everything from IP or ASN Y across all users
- All permission changes in organization Z this quarter
- All failed sign-ins followed within an hour by a success, same account
- All impersonation sessions, ever
- All API key creations by accounts less than a day old
Index for these deliberately — actor, organization, action prefix, time — and test the queries before you need them. Timing your first "show me everything for this user" query during an active incident is a bad way to discover it takes eleven minutes.
Then alert on a small number of high-signal events, delivered somewhere a human reads: refresh token reuse, MFA disabled on an admin account, a permission grant to a brand-new user, impersonation outside business hours, a spike in failed sign-ins across many accounts. Five well-chosen alerts that fire rarely beat fifty that get muted in a week.
Exposing logs to customers
B2B customers will ask for their audit logs — sometimes as a feature request, more often as a line item in a security questionnaire. It is a genuine differentiator, and it has one hard requirement.
Filter by tenant at the data layer, not in the handler. A customer-facing audit log endpoint that constructs a query with an organization ID taken from the request is one missing check away from exposing every tenant's authentication history — which is close to the worst possible leak, since it contains IPs, emails, and behavioural patterns for other companies' employees. Enforce it with row-level security or an equivalent backstop, as described in multi-tenant auth design.
Beyond isolation: expose a documented, stable schema (customers will build on it, and it becomes a contract), offer export in a format their SIEM ingests, redact fields that belong to your internals rather than to them, and make it clear which events are included — an incomplete log presented as complete is worse than none.
Key takeaways
- Audit logs are not application logs. Different retention, different mutability, and they must never be sampled.
- Record every event that changes who can access what, including the failures — with before/after values on permission changes.
- Never log credentials or attempted passwords. Log a failure category.
- Use a stable, namespaced schema with
organization_id,request_id, and a first-classimpersonated_by. - Resolve the erasure-versus-immutability conflict deliberately — crypto-shredding or pseudonymization — before someone asks.
- Hash-chain entries and anchor the head externally. Write with a credential that cannot update or delete.
- Index for the queries you will run under pressure, and alert on a handful of high-signal events.
- Customer-facing logs must filter by tenant at the data layer.
EmbedAuth writes a structured audit event for every authentication, session, credential, and membership change, scoped per organization and queryable from the dashboard — so the record exists before the day you need it.
Written by
Emilian GheoneaSenior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.
Related articles
Security
Webhook Signatures: Verifying What Your Server Is Told
A webhook endpoint is an unauthenticated API that changes your data. How to sign and verify properly — HMAC over the raw body, timestamps and replay windows, constant-time comparison, key rotation, and the idempotency and SSRF issues on both sides.
Aug 3, 2026 8 minReadSecurity
Stopping Fake Signups Without Punishing Real Users
Why bots register for your product, why CAPTCHA is the wrong first move, and a layered signup defense — honeypots, timing, disposable-domain policy, invisible attestation, progressive trust, and measuring the friction you're adding.
Aug 1, 2026 8 minReadSecurity
Credential Stuffing: Why Your Login Page Is Someone Else's Business Model
How stuffing attacks are actually run in 2026 — combo lists, residential proxies, config files and solver services — and the layered defenses that work, from breached-password checks to risk scoring and the metrics that reveal an attack in progress.
Jul 31, 2026 8 minReadSecurity
Protecting Auth Endpoints: Rate Limiting and Brute-Force Defense
How to design rate limiting for login, OTP, and reset endpoints — algorithms, what to key on, lockout strategies, and avoiding the traps that let attackers through or lock out real users.
Jun 12, 2026 8 minRead