One row per thing that happened.
Append-only, per-app, 365-day retention by default. Every signin attempt, MFA challenge, password change, session revoke, OAuth start — everything — has a row. The same rows feed the webhook bus.
Row shape
From tillauth_audit_log:
{
id: bigint // monotonic, generated-always
ts: timestamptz // when it happened
app_id: uuid
user_id: uuid | null // null for pre-account events
action: text // 'signin.password.ok' etc.
result: 'ok'|'error'|'denied'
reason: text | null // safe to show in customer UI
ip: text | null
ip_country: text | null // 2-letter, MaxMind-resolved
user_agent: text | null
metadata: jsonb // action-specific detail (<4KB)
}Action vocabulary
Actions are stable — they don't change once shipped. New events get new actions; existing actions don't get renamed. The dotted prefix is the category; the suffix is the outcome.
Signin
signin.password.ok— primary password success (metadata may carrymfa).signin.password.bad_credentials— wrong password.signin.password.locked— account locked due to repeated failures.signin.password.rate_limited— too many attempts from this IP / account.signin.password.unverified— credentials right, email not yet verified.signin.passkey.ok·signin.passkey.unknown_credential·signin.passkey.verify_failedsignin.passkey.counter_regression— credential locked, see passkeys.signin.magic.sent·signin.magic.ok·signin.magic.expired·signin.magic.ip_mismatchsignin.oauth.started·signin.oauth.ok·signin.oauth.error
Signup
signup.password.ok·signup.password.error·signup.duplicate_email
MFA + passkeys
mfa.totp.enrolled·mfa.totp.disabled·mfa.totp.bad_code·mfa.totp.requiredpasskey.registered·passkey.removed
Password lifecycle
password.changed·password.reset.requested·password.reset.ok
Sessions + devices
session.refreshed·session.theft_detecteddevice.new
verify.email.sent
Reading the log
From the admin dashboard: the per-app/<org>/auth/<appId>/audit page paginates the full history; the org-wide /<org>/auth/audit page shows a 14-day rolling window across every app. Filters: result, action category, app.
From the API:GET /api/auth/admin/apps/<appId>/audit with ?since=…&action=…&result=…. Paginate via ?cursor=<id>.
As webhook events: see webhooks — the same action token is the event type your endpoint receives.
Retention
Default 365 days. Per-app override:settings.audit_retention_days. Minimum 30; we don't accept shorter for compliance reasons. Background sweeper runs nightly, deletes rows older than the configured retention.
What it does not contain
- Passwords or password hashes — never. Even on failure, the action records "wrong password" not the value.
- TOTP secrets, backup codes, refresh tokens — same.
- OAuth tokens — the action records the provider's
subject, never the access or refresh token. - Full request/response payloads — see webhooks for those.
Next: DEK rotation · or custom domains.