ADR-0041: Mobile auth — Go-minted JWT with refresh-on-401, offline grace, and verified-identity gate

Status

Accepted (pending FieldForce Phase 1 implementation)

Tags

fieldforce, digital-worker, mobile, flutter, authentication, jwt, security, offline, verified-identity, better-auth

Decision

The mobile apps authenticate with a JWT minted by the Go backend (the same token authenticates both the API and PowerSync sync). The client never becomes an identity authority — it holds and refreshes tokens, the backend decides validity.
  • Access token: 30–60 min. Refresh token: 7–30 days.
  • Refresh-on-401 interceptor: on a 401, the HTTP client pauses the request, refreshes the access token via the Go refresh endpoint, and retries transparently.
  • Offline grace: the app stays usable on cached data with an expired access token as long as the refresh token is still valid. It hard-locks only when the refresh token expires or refresh is explicitly rejected. “Can reach the server” is decoupled from “session is still valid.”
  • Token storage: flutter_secure_storage (Keychain/Keystore-backed) only — never SharedPreferences.
  • Verified-identity hard gate: the security spine, enforced via a centralized go_router redirect guard. Unverified users route to a verification flow; gated screens/actions require verified status.
  • The same JWT identity drives PowerSync Sync Rules bucket access (ADR-0040), so a device syncs only its own user/team subset.

Why

The backend already issues stateless JWTs with direct Postgres reads and treats the Go backend as a read-only Better Auth consumer (ADR-0005, ADR-0006). The mobile client must align with that model rather than inventing a parallel auth scheme: the token is minted server-side, and the device is never trusted to assert identity. The must-record nuance is the offline-grace decoupling. Token refresh requires network, but a field worker in a dead zone cannot refresh — yet must keep working. Treating “cannot reach server” the same as “session invalid” would lock workers out exactly when offline-first matters most. So the design deliberately separates the two: an expired access token does not lock the app while the refresh token remains valid; only refresh-token expiry or an explicit rejection locks it. A future implementer could naively hard-lock on access-token expiry and break offline use — the distinguishing principle is network-unreachable is not session-invalid. The verified-identity gate mirrors the Digital Worker security spine (ADR-0035, ADR-0036): chat/device identities are untrusted until the backend verifies them, and gated actions require verified status. Rejected alternatives:
  • Long-lived single access token, no refresh. Simpler, but a stolen token is valid for its whole life and there is no rotation. Rejected — short access + refresh rotation is the standard security posture.
  • Hard-lock on access-token expiry regardless of refresh validity. Simpler offline logic, but breaks offline-first for field workers in dead zones. Rejected — defeats the core requirement.
  • Tokens in SharedPreferences / app prefs. Unencrypted; trivially extractable. Rejected — secure storage only.
  • Client-side identity verification. Makes the device an identity authority. Rejected — the backend verifies; the gate enforces.

Consequences

  • An offline grace policy must be implemented and tested: app remains functional on cached data with an expired access token until refresh-token expiry.
  • The refresh-on-401 interceptor must correctly queue and replay in-flight requests during a refresh to avoid duplicate refreshes / lost requests.
  • PowerSync must be wired to fetch/refresh the JWT from the same backend token source, so sync auth and API auth never diverge.
  • The self-hosted PowerSync Service must be able to verify the Go-minted JWT: the Go backend exposes a JWKS endpoint (preferred over a shared HS256 secret) and the token carries the sub/aud claims PowerSync expects — sub drives Sync Rules bucket parameters. This is real backend work to schedule with Phase 1, not just client wiring.
  • What happens to local data at hard-lock follows the lock-vs-wipe policy in ADR-0049 (passive expiry retains the DB; explicit rejection wipes it).
  • Exact TTLs within the 30–60 min / 7–30 day ranges are finalized against backend session policy.
  • The verified-identity guard adds a routing layer; unverified state UX must be designed (verification flow vs locked screen).

Rules for agents

  • The Go backend mints the JWT; the client never asserts identity. The same token authenticates API and PowerSync.
  • Access token 30–60 min, refresh token 7–30 days; refresh-on-401 with transparent retry.
  • Implement offline grace: expired access token does NOT lock the app while the refresh token is valid; hard-lock only on refresh-token expiry or explicit rejection.
  • Store tokens ONLY in flutter_secure_storage; never SharedPreferences.
  • Enforce the verified-identity gate via a centralized go_router redirect guard; gated actions require verified status.
  • The Go backend exposes JWT verification material (JWKS preferred) for the PowerSync Service; API auth and sync auth share one token with the claims both consumers need.