Skip to main content
GitHat

GitHat Audit Logs

Developer reference for GitHat's audit log system: event catalog, DynamoDB item shape, TTL/retention rules, and the query endpoint.

Overview

GitHat records security-relevant events to a dedicated DynamoDB table (default name: GitHat-AuditLog, overridable via the AUDIT_LOG_TABLE environment variable). The table is the single source of truth for who did what, when — it feeds the user-facing sign-in activity view, SOC 2 evidence, and, when an appId is present, fires webhook and WebSocket events to app subscribers.

Three internal functions write to this table. writeAuditEvent is the canonical path for security-sensitive events (key rotation, password change, GDPR erasure, org-level actions). recordAuditEvent is a convenience wrapper for auth handlers: it calls writeAuditEvent, then fans out webhooks and broadcasts a realtime message over WebSocket. logAuditEvent is a legacy function used by agent, MCP, and Eliza handlers that writes events co-located with the resource record rather than to GitHat-AuditLog. Audit failures never throw — errors are caught, logged to console.error, and the caller's user-facing response is unaffected.

DynamoDB item shape

Every item written by writeAuditEvent (and by recordAuditEvent, which delegates to it) has the following fields.

pk — partition key. Derived from the actor: USER#<userId>, ORG#<orgId>, or APP#<appId>. For auth-handler events the actor type is always USER.

sk — sort key. Format: EVENT#<ISO8601 timestamp>#<ULID>. The ULID provides monotonic ordering within the same millisecond. DynamoDB's ScanIndexForward=false returns events newest-first.

event_type — the action string (same value as the action field, duplicated for the GSI1 projection that allows platform-wide queries, e.g. all key.created events across every user).

actor_type — the string type from the actor param: USER, AGENT, or APP.

actor_id — the id from the actor param.

action — the event type string. See the event catalog below.

target_type — optional; the entity type that was modified (USER, SESSION, APP, ORG). null if not set.

target_id — optional; the id of the modified entity. null if not set.

metadata — free-form JSON object. Capped at 4,096 bytes (METADATA_MAX_BYTES). If the serialized metadata exceeds 4 KB the entire object is replaced with { _truncated: true, _reason: 'metadata exceeded 4KB cap' }.

created_at — ISO 8601 timestamp string at millisecond precision.

ttl_at — Unix epoch seconds. DynamoDB TTL attribute. Set to created_at + 2557 days (approximately 7 years). This hard-coded constant is NOT controlled by the AUDIT_LOG_TTL_DAYS environment variable — that variable only applies to the legacy logAuditEvent function (default 90 days), which writes to resource co-located tables, not to GitHat-AuditLog.

logAuditEvent items have a different shape: pk is the caller-supplied partition key (e.g. AGENT#<wallet>, MCP#<id>, ELIZA#<id>), sk is LOG#<ISO8601>#<UUIDv4>, and the fields are eventType, action, actor, ip, details, createdAt, and ttl (Unix epoch seconds, default 90 days from AUDIT_LOG_TTL_DAYS env var).

Retention and TTL

writeAuditEvent items expire after 2557 days (~7 years). This constant is hard-coded in audit.js and is not configurable via environment variable. The code comment explains the rationale: the previous limit of 365 days was flagged as below SOC2/HIPAA/PCI expectations for an identity provider holding payment-adjacent data. S3/Glacier archival before TTL expiry is planned but not yet implemented; DynamoDB is the sole store for now.

logAuditEvent items (agent/MCP/Eliza tables) use a separate TTL driven by the AUDIT_LOG_TTL_DAYS environment variable, which defaults to 90 days.

Items are append-only. There is no update or delete path in the application code. DynamoDB TTL is the only removal mechanism.

Event catalog

The following action strings appear in real call sites. Underscore-style strings are written by recordAuditEvent; dot-style strings are written directly via writeAuditEvent.

Authentication: login_success (password, OAuth providers, passkey, magic-link, and MFA completion paths all emit this), login_failed (wrong password or unknown user — actor is the userId when known, 'unknown' otherwise; reason field is no_user or wrong_password), mfa_login_failed (bad TOTP, SMS, or recovery code; reason distinguishes invalid_verification_code, ticket_replay, or ticket_replay_race), token_refresh (successful refresh-token rotation; metadata includes oldSessionId and newSessionId), refresh_token_reuse_detected (stolen or replayed refresh token; reason field on the concurrent-rotation variant is concurrent_rotation_race).

Registration and verification: user_created, email_verified.

MFA lifecycle: mfa_enabled (TOTP), mfa_disabled (TOTP), mfa_sms_enroll_requested (OTP sent to phone), mfa_sms_enabled (enrollment confirmed), mfa_sms_disabled.

Passkeys: passkey_added, passkey_removed, passkey_login_failed (reason field: verification_failed, no_user_verification, or cloned_credential).

Sessions: session_revoked (single session; target_type=SESSION, target_id=sessionId, isCurrent flag in metadata), session_revoked with revokeAll:true (all sessions; no target set).

Account: password.changed (dot-style, writeAuditEvent), data_erasure (GDPR Article 17 delete-me; metadata includes gdpr_article, oauth_subjects_unlinked, sessions_revoked), data_export (user data export).

Org operations: member.removed, member_role_changed (metadata includes actorUserId, orgId, fromRole, toRole), org.data_export, org.data_export_too_large, org.erasure_requested.

API keys: key.created (metadata includes keyId, keyPrefix, keyType, keyName, orgId).

Agent/MCP/Eliza (logAuditEvent to resource tables, not GitHat-AuditLog): AGENT_REGISTERED, AGENT_SUSPENDED, AGENT_REACTIVATED, AGENT_TOKEN_ISSUED, MCP_REGISTERED, MCP_SUSPENDED, MCP_REACTIVATED, MCP_VERIFIED, MCP_VERIFY_FAILED, ELIZA_REGISTERED (inferred from register.js), ELIZA_SUSPENDED, ELIZA_REACTIVATED.

OAuth social logins (login_success with a provider field): github, google, google_onetap, facebook, apple, microsoft, cognito, instagram, tiktok.

Webhook delivery: logAuditEvent writes WEBHOOK# items to the audit log table when a webhook endpoint is auto-disabled after repeated failures.

Querying — the real endpoint

GET /auth/audit-log returns the authenticated user's own events. Authentication is required: present a valid user access token either as Authorization: Bearer <token> or via the __Host-githat_access cookie (production) or githat_access cookie (development fallback).

The handler queries DynamoDB with pk = USER#<userId> and sk beginning with EVENT#. Results are sorted newest-first (ScanIndexForward: false). The endpoint never returns events for other users — the pk is derived from the verified JWT, not from a query parameter.

Supported query parameters:

type — filter by event type. Matches against both the event_type attribute and the action attribute (DynamoDB FilterExpression, applied after the key query). Example: ?type=login_success

since — ISO 8601 timestamp. Restricts the key condition to sk >= EVENT#<since>, effectively returning only events at or after that moment. Example: ?since=2026-01-01T00:00:00Z

limit — integer 1–100, default 50. Clamped server-side: values below 1 become 1, values above 100 become 100.

cursor — base64-encoded JSON of DynamoDB LastEvaluatedKey for pagination. Use the nextCursor value from the previous response. An invalid cursor string is silently ignored.

The response body is { events: [...], nextCursor: '<base64>' | null }. nextCursor is null when there are no more pages.

GET /auth/audit-log?limit=20&type=login_success HTTP/1.1
Authorization: Bearer <access_token>

Response shape

Each item in the events array is the raw DynamoDB item as stored. Fields you can reliably expect on every event written by writeAuditEvent/recordAuditEvent:

{
  "pk": "USER#usr_01J...",
  "sk": "EVENT#2026-06-16T10:23:45.123Z#01J...",
  "event_type": "login_success",
  "actor_type": "USER",
  "actor_id": "usr_01J...",
  "action": "login_success",
  "target_type": null,
  "target_id": null,
  "metadata": {
    "ipAddress": "203.0.113.4",
    "userAgent": "Mozilla/5.0 ...",
    "country": "US",
    "email": "user@example.com"
  },
  "created_at": "2026-06-16T10:23:45.123Z",
  "ttl_at": 1957276625
}

Metadata fields on auth events

recordAuditEvent always merges the following fields into metadata before writing, in addition to any caller-supplied metadata:

ipAddress — the client's IP. Derived from API Gateway's requestContext.identity.sourceIp (the TCP source, not spoofable). Falls back to the last X-Forwarded-For entry (the most recent trusted hop), never the first entry. Falls back to 'unknown' when the request object is absent.

userAgent — from the User-Agent or user-agent header. 'unknown' if absent.

country — from the CloudFront-Viewer-Country header. null if absent.

Caller-supplied metadata fields vary by event. Common fields: email (user's email address), provider (OAuth provider name or 'password'), mfaUsed (boolean), mfaMethod ('totp', 'sms', or 'recovery_code'), reason (on failure events), isNewUser (on OAuth events for first-time signups).

Immutability

Items are written with a plain PutCommand and no ConditionExpression guarding overwrites. The sort key includes a ULID that is unique per write, so two concurrent writes for the same actor and timestamp produce two distinct items rather than a collision. There is no application-level update or delete path. The only removal mechanism is DynamoDB's TTL expiry, which fires approximately 48 hours after ttl_at (per AWS's TTL accuracy guarantee) and is not guaranteed to fire at the exact second.

Because the PK/SK space is not write-protected by a ConditionExpression, a hypothetical attacker with direct DynamoDB access (IAM-level, not API-level) could overwrite an item whose SK they already know. The API surface exposed through /auth/audit-log is read-only and provides no write, update, or delete endpoint.

Webhook and realtime fanout

When recordAuditEvent is called with a non-null appId, it fires two side effects after writing the DynamoDB item. First, it calls fanOutWebhooks(appId, webhookEvent, payload) where webhookEvent is the action string with underscores replaced by dots (e.g. login_success becomes login.success). Second, it calls broadcast(appId, 'audit.<userId>', realtimePayload) to push the event over WebSocket to any connected subscribers. Both calls are fire-and-forget; errors are swallowed so they cannot affect the response returned to the caller.

Environment variables

AUDIT_LOG_TABLE — DynamoDB table name for writeAuditEvent and the /auth/audit-log query handler. Default: GitHat-AuditLog.

AUDIT_LOG_TTL_DAYS — TTL in days applied only by the legacy logAuditEvent function (agent/MCP/Eliza co-located events). Default: 90. Has no effect on writeAuditEvent, which uses the hard-coded 2557-day constant.

AWS_REGION — region for the DynamoDB client instantiated in audit-log.js. Default: us-east-1.