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

Client → Go backend (Bearer JWT)
Go validates JWT signature locally (BETTER_AUTH_SECRET, no DB hit)
Go extracts: userId, email, role, organizationId, orgRole

Authorization check:
  1. Check Redis cache (key: perm:<userId>:<orgId>)
  2. Cache miss → query members table → resolve permissions → cache 5m
  3. Role change event (NATS member.role.changed) → invalidate cache immediately

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)

// Syncing user data into a Go-owned table — creates dual source of truth
func handleUserCreated(event UserCreatedEvent) {
    goUserRepo.Insert(ctx, User{ID: event.ID, Email: event.Email}) // wrong
}

// Writing to Better Auth tables from Go
db.Table("users").Where("id = ?", id).Update("role", "admin") // wrong

Good pattern

// Read-only query on Better Auth tables
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    var row struct { ID, Email, Name, Role string }
    return db.Table("users").Where("id = ?", id).Scan(&row).Error
}

// Cache invalidation on role change
func (c *MemberRoleChangeConsumer) Handle(event MemberRoleChangedEvent) {
    cache.Delete(ctx, fmt.Sprintf("perm:%s:%s", event.UserID, event.OrgID))
}