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.
-
Credentials are email + bcrypt-hashed password. Users register via
UserService/Createwith 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 theUserrecord (password_hashfield). Registration enforces a minimum password length of 8 characters (no complexity rules for v1), basic email format validation, and case-insensitive email uniqueness. -
Login issues an HS256 JWT.
UserService/Loginverifies 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. Therolesclaim was added later by ADR-0008. -
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 fromJWT_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. -
A Connect interceptor enforces default-deny. Tokens travel in the
Authorization: Bearer <token>header. Aninternal/authinterceptor, chained ahead of telemetry on every service registration, validates the signature and expiry on each request, extracts aUserContext(UID + email) into the request context, and rejects anything else. Only two endpoints are public:UserService/Create(registration) andUserService/Login. The interceptor's internals were later reshaped into a singleauthorize/Allowseam by ADR-0002; the authentication contract here is unchanged. -
Password hashes never leave the server. Every read path that returns a
Userstripspassword_hashbefore responding; login and registration responses do the same. -
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
UserContextis 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.