ADR-0005: Stateless JWT authentication with direct PostgreSQL reads

Status

Accepted

Tags

authentication, jwt, better-auth, postgresql, redis, security, multi-tenancy

Decision

The Go backend validates JWTs using a shared BETTER_AUTH_SECRET (HS256, stateless — no DB lookup on every request). Authorization checks query the Better Auth PostgreSQL tables directly. Permission results are cached in Redis with a 5-minute TTL and invalidated in real-time via NATS when roles change.

Why

The Go backend is a read-only consumer of identity data managed by the Bun monolith (Better Auth). Two alternatives were rejected:
  • NATS user sync: replicating user/org data into Go-owned tables adds synchronization lag, dual-write complexity, and eventual consistency risk. Rejected.
  • Per-request API call to Bun: adds network round-trip on every protected request. Rejected.
Direct queries to Better Auth’s PostgreSQL schema give absolute consistency (single source of truth), no replication lag, and lower latency than distributed sync.

Flow

Key constraints

  • Go is read-only on Better Auth tables (users, members, organizations, accounts)
  • All mutations (create user, assign role, invite member) go through the Bun backend’s Better Auth API
  • JWT is NOT cached — signature verification is local and fast
  • Only permission resolution (role → permissions mapping) is cached

Rules for agents

  • Never write to users, members, or organizations tables from Go — SELECT only
  • JWT validation must use BETTER_AUTH_SECRET (HS256) — do not implement a separate key management system
  • Permission cache key format: perm:<userId>:<organizationId>
  • On member.role.changed or member.removed NATS events, invalidate the corresponding cache key immediately
  • Use the members table for org role checks, not the JWT claim alone — claims may be stale if the role changed after login
  • Required indexes: idx_users_role, idx_members_user_org, idx_members_organization_id_role

Bad pattern (do not generate)

Good pattern