TILLAUTH · OPERATE

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 carry mfa).
  • 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_failed
  • signin.passkey.counter_regression — credential locked, see passkeys.
  • signin.magic.sent · signin.magic.ok · signin.magic.expired · signin.magic.ip_mismatch
  • signin.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.required
  • passkey.registered · passkey.removed

Password lifecycle

  • password.changed · password.reset.requested · password.reset.ok

Sessions + devices

  • session.refreshed · session.theft_detected
  • device.new

Email

  • 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.