App reference

ADR-0007: Stateless JWT authentication with bcrypt-hashed credentials

Metadata

  • Status: Accepted
  • Date: 2025-10-28
  • Deciders: Eagraí Clainne Team
  • Converted from: RFC 001 (Basic Authentication), docs/rfcs/001-auth-rbac/ — now removed
  • Related: ADR-0008 (RBAC, formerly RFC 002); ADR-0009 (Service-Layer Authorization, formerly RFC 003); ADR-0002 (Centralised RPC authorization behind auth.Allow)

Context

The Eagraí Clainne family-organization API originally had no authentication at all — every RPC was open to anyone who could reach the server. Before any authorization work could land (role-based access in ADR-0008, resource ownership in ADR-0009), the system needed a way to identify users and carry an authenticated identity through each request.

The constraints were: a small self-hosted deployment, Connect-RPC as the transport, protobuf-defined services, and a preference for minimal infrastructure — no session store, no external identity provider. Explicitly out of scope at this stage: OAuth2/OIDC social login, refresh tokens, password reset, and two-factor authentication.

Decision

Authentication is stateless JWT over email/password credentials, enforced at the RPC edge by a Connect interceptor.

  1. Credentials are email + bcrypt-hashed password. Users register via UserService/Create with a plain-text password (TLS protects transit); the server hashes it with bcrypt at the default cost factor (10) and stores only the hash on the User record (password_hash field). Registration enforces a minimum password length of 8 characters (no complexity rules for v1), basic email format validation, and case-insensitive email uniqueness.

  2. Login issues an HS256 JWT. UserService/Login verifies the password against the stored bcrypt hash and returns a token signed with HMAC-SHA256. Claims are deliberately minimal — sub (user UID), email, iat, exp — never passwords, hashes, or other sensitive data. The roles claim was added later by ADR-0008.

  3. Token lifetime and secret are environment-configured. Default expiry is 24 hours, overridable via TOKEN_EXPIRY (capped in policy at 7 days). The signing secret comes from JWT_SECRET — minimum 32 bytes of cryptographically random material (openssl rand -hex 32), never committed to version control. There are no refresh tokens; expiry forces re-authentication.

  4. A Connect interceptor enforces default-deny. Tokens travel in the Authorization: Bearer <token> header. An internal/auth interceptor, chained ahead of telemetry on every service registration, validates the signature and expiry on each request, extracts a UserContext (UID + email) into the request context, and rejects anything else. Only two endpoints are public: UserService/Create (registration) and UserService/Login. The interceptor's internals were later reshaped into a single authorize/Allow seam by ADR-0002; the authentication contract here is unchanged.

  5. Password hashes never leave the server. Every read path that returns a User strips password_hash before responding; login and registration responses do the same.

  6. Email lookup is a table scan, accepted for v1. Login finds users by scanning the user table with case-insensitive matching. This is O(n) and known to be inefficient; it was accepted at v1 family scale with the need for an email index documented for later.

The implementation lives in internal/auth/ (config, JWT generate/validate/parse, context helpers, interceptor), with golang.org/x/crypto/bcrypt and github.com/golang-jwt/jwt/v5 as the only new dependencies.

Consequences

Positive

  • Stateless server. No session store or Redis; any server instance can validate any token with only the shared secret. Horizontal scaling and restarts need no session migration.
  • Foundation for authorization. The authenticated UserContext is the hook that ADR-0008 (roles claim, permission matrix) and ADR-0009 (ownership checks) build on without touching the transport again.
  • Small, auditable surface. One interceptor, one token format, two public endpoints. Security posture is default-deny with minimal claims.
  • Minimal dependencies. bcrypt and golang-jwt on top of the existing Connect stack.

Negative / trade-offs

  • No revocation. A stolen token is valid until expiry; there is no server-side kill switch. Mitigated by the 24-hour default lifetime and the ability to rotate JWT_SECRET (which invalidates all tokens at once).
  • Symmetric signing. HS256 means every verifier holds the signing secret. Fine for a single-service deployment; a move to multiple verifying services would motivate asymmetric keys.
  • Re-authentication burden. Without refresh tokens, users log in again at least daily. Accepted for v1; refresh tokens remain a future enhancement.
  • O(n) email lookup. Login cost grows with the user table. Acceptable at family scale, but an email index is required before the user count grows materially.
  • No rate limiting or auth audit logging yet. The login endpoint is open to brute-force attempts beyond bcrypt's inherent cost; both were noted as follow-ups, not shipped in v1.

Alternatives considered

  • Session-based authentication. Server-side sessions allow immediate revocation and sidestep token-expiry design. Rejected: they require a session store (database or Redis) and make the server stateful — added infrastructure and complexity that JWT avoids at this scale.
  • Long-lived API keys. Simpler to implement, no expiry management. Rejected: no standard format, awkward for embedding user context, and entirely manual lifecycle management. JWT gives a standard, self-describing, expiring credential.
  • Stricter password policy. Complexity rules beyond the 8-character minimum were considered and deliberately deferred; length-only is the v1 decision, revisitable later.