The goal of good auth architecture isn’t cleverness. It’s to have zero surprises when you grow from 10 to 10,000 to 100,000 users. Here’s the four-component shape that scales predictably.
Component 1: Identity layer
The identity layer answers “who is this user?” It owns:
- User records (one per human identity)
- Membership records (user in tenant with role)
- Credential records (password hashes, OAuth provider subjects, WebAuthn credentials)
This layer is typically your Postgres database, or a managed identity service like LoginWith. It’s the only place with ground truth about identity.
Boundary: other services don’t read this directly. They read through the identity layer’s API.
Component 2: Session layer
The session layer answers “is this request authenticated?” It owns:
- Session records (session_id → user_id + metadata)
- Token records (for API keys, refresh tokens)
- Session lifecycle (creation, expiration, revocation)
This is usually your main database for up to 100k users, then moves to Redis/Valkey as session-lookup latency becomes relevant.
Boundary: the session layer is consulted on every authenticated request. It should be fast (< 1ms) and always available. If it goes down, your product goes down.
Component 3: Request authentication
The request-auth layer is the middleware that runs on every request. It:
- Reads the session cookie (or bearer token)
- Looks up the session in the session layer
- Attaches the user + tenant context to the request
- Rejects the request if the session is invalid
This is 50 lines of code that never changes once it’s written. Keep it simple. Don’t put business logic here.
Boundary: downstream handlers receive req.user and req.tenant and can assume they’re valid. If either is null, the handler decides whether that’s OK (public endpoint) or a 401.
Component 4: Tenant context
The tenant context layer scopes all data access to the current tenant. It:
- Derives the “current tenant” from the session or request (subdomain, header, URL path, session)
- Enforces that every query is filtered by the current tenant
- Optionally: uses Postgres row-level security to enforce this at the DB layer
Boundary: every query in your app goes through this. No endpoint bypasses it. No admin UI bypasses it without explicit opt-in.
What not to do
Don’t collapse these layers. Specifically:
- Don’t put identity in the session. Session records should reference user IDs, not embed user data. When a user changes their email, you shouldn’t have to update 50 session records.
- Don’t embed tenant logic in request auth. Request auth says “valid user.” Tenant context says “this user’s current tenant is X.” Keep them separable.
- Don’t let application code read raw session data. Always go through the auth middleware — it’s the only place that validates.
The scaling path
- 10 users: Postgres, in-process sessions, one database
- 1k users: same, plus indexes, plus a session cleanup cron
- 10k users: same, plus maybe a dedicated auth worker pool
- 100k users: move session layer to Redis, keep identity layer in Postgres
- 1M users: shard identity by user_id range, keep session layer in Redis cluster
At every step, the component boundaries stay the same. That’s the point.