Authentication Flow with Better Auth Plugins

This document describes the complete authentication and authorization flow across the Gremlin system with the Better Auth plugin architecture.
This document covers how you sign in and how tokens are minted and verified. For which module a signed-in user can reach, see Module Access Control.

System Architecture Overview

System Architecture Overview
An interactive, zoomable version of this diagram is available at authentication-flow.html.
Key boundaries
  • Bun owns every auth write. Go reads the auth tables directly but never writes them (ADR-0006). All role mutations go through the Better Auth API on Bun.
  • Two secrets, two token types. The app-JWT is HS256 signed with BETTER_AUTH_SECRET, shared by Bun and Go. The PowerSync sync token is asymmetric, signed with a private key from the jwks table, so PowerSync only ever receives public keys.
  • Redis is the revocation channel, not a permission cache. Both backends read the same authInvalidBefore:<userId> marker.
  • Rental bypasses Bun. /api/rental/* is proxied by the SolidStart server straight to Go, forwarding the session cookie with Better Auth’s HMAC suffix stripped.
  • Mobile never calls Go directly. The app has a single API_BASE_URL pointing at Bun; every Go-owned mobile path (/api/mobile/sync/*, the fieldforce routes) needs an explicit proxy route in api-gateway/routes/index.ts or the app gets a misleading 404. Go re-validates the JWT behind that proxy.

1. User Registration Flow

Step-by-Step Process

1.1 User Initiates Sign-Up

1.2 Bun Backend Processes Registration

Sign-up and sign-in return a session, not a JWT. Better Auth issues a session cookie (or a bearer session token). The app-JWT that the Go backend verifies is minted by a separate exchange — see Section 3.

1.3 Frontend Receives the Session


2. User Login Flow

Step-by-Step Process

2.1 User Initiates Sign-In

2.2 Bun Backend Verifies Credentials

2.3 Frontend Receives the Session


3. App-Token Exchange (Session → JWT)

Clients that cannot rely on cookies — the mobile app, and any caller that needs a bearer token for the Go backend — exchange their live session for an HS256 app-JWT.
This endpoint deliberately does not use the generic authMiddleware. That middleware also accepts an app-JWT, which would let a stolen or logged-out token refresh itself indefinitely. getSession honours only a live session, so revoking the session cuts off new mints.
Two different tokens exist. Do not confuse them.The sync token is short-lived and comes from Better Auth’s jwt plugin (/api/auth/token + /api/auth/jwks), so PowerSync receives verify-only public keys and never the shared secret.
buildRoles is re-implemented on both sides of the trust boundary — Bun (services/auth/api.ts) and Go (internal/api/http/middleware/better_auth_validator.go). The behaviour is pinned by contracts/auth/role-revocation-vectors.json; change one side without the other and TestAuthGoldenVectors (Go) or auth-golden.test.ts (Bun) fails.

4. Authenticated Request Flow (Go Backend)

Step-by-Step Process

4.1 Frontend Makes Authenticated Request

4.2 Bun Backend Verifies JWT (if applicable)

4.3 Go Backend Processes Request


5. Organization Management Flow

Organization writes are split across two surfaces. Do not assume everything lives under /api/auth/organization/*:
  • Better Auth plugin endpoints — mounted by the catch-all app.all('/api/auth/*') handler. Used for org creation and the plugin’s own member primitives.
  • Custom self-service API — a Hono router at /api/v1/organizations/* (services/organizations/api.ts), plus custom roles at the same mount and a platform-admin variant at /api/v1/admin/organizations/*.
The route list in Section 12 is authoritative.

5.1 Create Organization

5.2 Invite Member to Organization

A members row is created only when the invitation is accepted — an invitation and a membership are separate records. Related routes on the same mount:

5.3 Change a Member’s Role


6. Permission Query Flow (Go Backend)

6.1 Authorization for Protected Operations

The Go permission cache is a per-process in-memory sync.Map with a 5-minute TTL (permission_service.go), not Redis. Consequences:
  • Each replica caches independently. There is no shared invalidation across pods.
  • InvalidateOrgCache is brute force — it replaces the whole cache object rather than deleting selected keys.
  • Redis is used in this system, but for app-JWT revocation (Section 9), not for this cache.

6.2 Cache Invalidation Flow

Invalidation happens inside the Bun backend, at the point of the write:
MemberRoleChangeConsumer is dead code. The file exists at internal/modules/organizations/adapter/inbound/messaging/member_role_change_consumer.go, but NewMemberRoleChangeConsumer has zero callers — no NATS member.role.changed subscription is wired. Earlier revisions of this document described that flow as live; it is not. Do not rely on it.

7. Role Hierarchy & Permissions

Platform Roles (Better Auth Admin Plugin)

users.role holds one value only. An account cannot be both landlord and adminPromoteToLandlord explicitly refuses accounts holding a staff role, because writing landlord would destroy it.The rental role is named landlord, not owner, on purpose: owner already means organization owner, and buildRoles flattens org and platform roles into one array, so reusing owner would make the two meanings indistinguishable inside a token.

Organization Roles (Better Auth Organization Plugin)

Stored in the members.role field, per (userId, organizationId). The permission set is resolved from defaultRolePermissions in permission_service.go — permissions are not stored in a table.
staff does NOT have leave:approve. Earlier revisions of this document said it did. Only owner and admin can approve leave. staff and member currently resolve to the same permission set.
field_team is a further org role used by the fieldforce module. It is not in defaultRolePermissions — it is checked inline in fieldforce handlers to restrict field workers from briefings and risk data. Organizations can also define custom roles (custom_roles table, referenced by members.custom_role_ids). For how these roles map to module access, see Module Access Control.

8. Error Handling

Common Error Scenarios


9. Security Measures

App-JWT Security

  • Signature: HS256 using the shared BETTER_AUTH_SECRET
  • Expiration: 7 days (APP_TOKEN_TTL in services/auth/api.ts)
  • Storage: HttpOnly cookie for web; secure storage on mobile
  • Transport: HTTPS only (enforced in production)

Token Revocation

A long TTL is safe because immediacy comes from server-side revocation, not a short expiry (services/auth/revocation.ts):
  • On any status, role, or permission change, a per-user marker authInvalidBefore:<userId> = now is written to Redis.
  • Both the Bun middleware and Go’s better_auth_validator.go reject any JWT whose iat predates the marker. Every outstanding token for that user dies at once.
  • The marker TTL is 8 days — one day longer than the longest-lived token — so it self-expires without unbounded growth.
  • A companion auth_versions table tracks a per-user version counter.
Error-handling policy is deliberately divergent between the two backends: on a revocation-store error, Go fails open and Bun fails closed. This asymmetry is intentional and is explicitly excluded from the shared golden-vector contract.

Password Security

  • Hashing: scrypt (Better Auth default), stored in accounts.password
  • Storage: hashed only, never plaintext
  • Comparison: constant-time comparison to prevent timing attacks
  • Minimum length: 8 characters (Better Auth default)

Database Security

  • Access Control: PostgreSQL user permissions restrict access
  • Connection: SSL/TLS encryption in production
  • Secrets: Environment variables, not hardcoded

Multi-Tenancy

  • Isolation: Organization data accessed via organizationId in context
  • RBAC: Role-based access control at organization level
  • Audit: Member actions logged for compliance

10. Migration from Old Auth System

Key Changes

  1. No NATS User Sync: Previously, users were synced via NATS events. Now they’re queried directly from PostgreSQL.
  2. No Local User Table (Go): Go backend no longer creates/maintains its own users table.
  3. Direct Query Pattern: Go backend queries Better Auth tables (users, members, organizations).
  4. Centralized Mutations: All write operations go through Better Auth API on Bun.

For Backend Developers

  • Use PermissionService for authorization checks
  • Query users/orgs using the provided repositories
  • Role changes reach Go through token revocation plus the 5-minute cache TTL — there is no NATS invalidation (see Section 6.2)
  • Permission checks are fast (cached, with a < 50ms latency target)

For Frontend Developers

  • Update user/org creation calls to use Better Auth API
  • Read operations remain unchanged (Go backend still provides queries)
  • Handle new permission error responses (403)

11. Monitoring & Troubleshooting

Key Metrics to Monitor

  • Auth latency: time to verify a JWT and fetch user data
  • Cache hit rate: permission cache effectiveness
  • Redis availability: the revocation marker store — Bun fails closed without it
  • Database query performance: user/org queries should be < 50ms

Common Issues


12. API Endpoints Summary

Authentication (Bun)

POST /api/auth/refresh-session does not exist — earlier revisions of this document listed it. To get a fresh app-JWT, re-run the /api/v1/auth/app-token exchange against a live session.

User Management (Bun - Admin Plugin)

Organization Self-Service (Bun — services/organizations/api.ts)

Mounted at /api/v1/organizations. Requires an authenticated org member.
Custom roles (createRolesAPI) and teams (createTeamsAPI) mount on the same prefix. Organization creation goes through the Better Auth organization plugin, served by the catch-all /api/auth/* handler.

Platform Admin (Bun — services/admin/api.ts)

Mounted at /api/v1/admin/organizations, every route behind adminOnlyMiddleware. Full CRUD over any organization, its members, custom roles, invitations, and audit log.

Rental Platform Role (Go)

User & Organization Queries (Go)


References

Source of truth