Most teams reach for Keycloak or Auth0 without thinking twice. That works until you’re hit with a $40k/month SaaS bill, a compliance audit that requires you to own the token store, or a product decision that forces you to federate auth across three unrelated services you actually control. At that point, knowing how OAuth providers work under the hood stops being academic.
This article walks you through designing an OAuth 2.1 authorization server from the ground up — how tokens are structured, how scopes should be designed (not just bolted on), and how to build a consent screen that isn’t a dark-pattern nightmare. We’re not building a toy. We’re building something you could actually run in production.
For a battle-tested open-source baseline to study or extend, look at Ory Hydra on GitHub — it’s the closest thing to a reference implementation of a headless OAuth 2.1 server.
What OAuth 2.1 Actually Changes
OAuth 2.0 was published in 2012 and the spec was deliberately loose. That looseness gave us a decade of footguns: implicit flow leaking tokens in URL fragments, response_type=token over non-HTTPS, clients skipping PKCE, and wildcard redirect URIs that made phishing trivially easy.
OAuth 2.1 (still in draft, but widely adopted) consolidates the best practices:
- PKCE is mandatory for all clients, including confidential ones. No exceptions.
- Implicit flow is removed. Gone. Dead. Use authorization code + PKCE.
- Redirect URIs must be exact matches. No partial matching, no wildcards in production.
- Refresh tokens for public clients must be sender-constrained or rotated on every use.
Design your provider to these rules from day one. Retrofitting is miserable.
Token Architecture
The authorization code is a one-time use, short-lived (60–120 seconds max) credential that the client exchanges for tokens. It proves the user authorized the client at the authorization endpoint.
Store it server-side. A simple authorization_codes table works fine:
CREATE TABLE authorization_codes (
code TEXT PRIMARY KEY, -- cryptographically random, 32+ bytes
client_id TEXT NOT NULL,
user_id UUID NOT NULL,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
code_challenge TEXT NOT NULL, -- PKCE S256 challenge
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE
);
On exchange, verify the code hasn’t been used (used = FALSE), hasn’t expired, and that the code_verifier hashes to code_challenge. Then immediately mark it used — don’t delete it yet, so you can detect replay attacks and revoke issued tokens if a used code is re-presented (this is the "code reuse" attack detection pattern from RFC 6819).
Access Tokens
Two schools of thought: opaque (random strings, server-side lookup) vs structured (JWT, self-contained). Both are valid. Here’s the real tradeoff:
Opaque tokens mean every API resource server must call your token introspection endpoint (POST /oauth/introspect per RFC 7662). That’s a network hop per request. Fine for small deployments, painful at scale without caching.
JWTs are self-contained — resource servers validate the signature locally. No network hop. But: you can’t revoke them before expiry without a distributed blocklist. Short expiry (5–15 minutes) is your mitigation.
For most self-hosted setups, JWT access tokens with a short TTL and a revocation blocklist (Redis or a small DB table) is the sweet spot.
A minimal JWT payload for an access token:
{
"iss": "https://auth.yourdomain.com",
"sub": "user-uuid-here",
"aud": ["api.yourdomain.com"],
"client_id": "your-client-id",
"scope": "openid profile email read:orders",
"exp": 1748889600,
"iat": 1748886000,
"jti": "unique-token-id-for-revocation"
}
jti (JWT ID) is critical for revocation. When a user logs out or revokes access, you add the jti to a blocklist. Resource servers check against it. Without jti, you can’t revoke a JWT before it expires — you’re just hoping for the best.
Sign with RS256 (RSA) or ES256 (ECDSA, faster and smaller keys). Publish your public keys at /.well-known/jwks.json. Never use HS256 in a multi-service environment — symmetric signing means every service that validates tokens also has the power to forge them.
Refresh Tokens
Refresh tokens are long-lived (days to months) and must be treated like passwords. Store them hashed:
CREATE TABLE refresh_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 of the actual token
client_id TEXT NOT NULL,
user_id UUID NOT NULL,
scope TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
rotated_at TIMESTAMPTZ, -- NULL = not yet rotated
revoked BOOLEAN NOT NULL DEFAULT FALSE,
family_id UUID NOT NULL -- for refresh token rotation chains
);
family_id is the key to refresh token rotation. Every refresh operation issues a new token and revokes the previous one — but they share the same family_id. If a rotated (already-used) refresh token is presented again, your server knows the token was stolen (or the client is buggy), and should immediately revoke the entire family.
def handle_refresh(incoming_token: str) -> TokenResponse:
token_hash = sha256(incoming_token)
record = db.get_refresh_token(token_hash)
if not record:
raise InvalidGrant("unknown token")
if record.revoked:
# Token was already rotated — possible replay attack
db.revoke_token_family(record.family_id)
raise InvalidGrant("token reuse detected, family revoked")
if record.expires_at < now():
raise InvalidGrant("token expired")
# Issue new access token and refresh token
new_refresh = generate_token()
db.rotate_refresh_token(
old_hash=token_hash,
new_hash=sha256(new_refresh),
family_id=record.family_id
)
return TokenResponse(
access_token=issue_jwt(record.user_id, record.scope),
refresh_token=new_refresh,
expires_in=900
)
Gotcha: Don’t forget to handle the race condition where two refresh requests arrive simultaneously with the same token (network retry). A database unique constraint on
token_hashand aFOR UPDATElock on the row prevents double-rotation. If the second request hits after rotation, it’ll seerevoked=TRUEand trigger the family revocation — which might be overly aggressive for a legitimate retry. Consider a short window (e.g., 30 seconds) where a rotated token is in a "recently rotated" state before triggering full family revocation.
Scope Design
Scopes are where most OAuth providers get it wrong. They either go too granular (50 scopes nobody can remember) or too coarse (admin scope that grants everything).
The Right Mental Model
A scope is a promise the authorization server makes to both the user and the resource server:
- To the user: "This is what you’re granting access to."
- To the resource server: "Tokens with this scope can perform these operations."
Design scopes around user-visible capabilities, not internal API methods. read:orders is a good scope. GET:/api/v2/orders is a terrible scope.
Scope Namespacing
Use a consistent naming convention. Two patterns work well:
Action:resource (GitHub-style):
read:profile
write:profile
read:repositories
delete:repositories
admin:org
service:resource:action (for multi-service setups):
billing:invoices:read
billing:payment_methods:write
shipping:orders:read
Pick one and stick to it. Mixing conventions is how you end up with an undocumented scope taxonomy that breaks when someone new joins the team.
Reserved OIDC Scopes
If you’re implementing OpenID Connect on top of OAuth (you should be — raw OAuth 2 is for machines, OIDC adds the user identity layer), these scopes have defined meanings:
openid— mandatory for OIDC, triggers ID token issuanceprofile— name, picture, locale, etc.email— email address and verification statusaddress— physical address (rarely used)phone— phone number
Don’t redefine these. Don’t put your own data in the ID token fields these scopes control.
Scope Downscoping
Your token endpoint should support requesting a subset of an already-granted scope. A client might have read:profile write:profile authorized, but for a specific operation want to request a token with only read:profile. This is called downscoping and it’s good security hygiene — principle of least privilege at the token level.
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=xxx&
scope=read%3Aprofile
The returned access token should have only the requested scope, as long as it’s a subset of what’s already authorized.
Gotcha: Never allow a refresh token exchange to expand scope. If the client requests a scope that wasn’t in the original authorization, reject it with
invalid_scope. This seems obvious but gets missed in implementations that don’t validate scope against the original grant.
The Consent Screen
The consent screen is the moment the user decides whether to trust a client with their data. Get it wrong and you’re either leaking user trust through dark patterns or blocking legitimate access with a UX that’s confusing enough that users click "deny" on valid requests.
What Must Be On Screen
At minimum, every consent screen must show:
- Who is asking — the client’s registered name and logo, not just a client ID
- What they’re asking for — human-readable descriptions of each scope, not the technical scope string
- What they can’t do — equally important, and often skipped
- For how long — if the client is requesting offline access (refresh tokens), say so explicitly
A bad scope display:
This app is requesting: read:profile, write:orders, offline_access
A good scope display:
Delivery Tracker wants to:
✓ View your name and profile picture
✓ Create and update orders on your behalf
✓ Access your account even when you're not using the app
✗ Access your payment methods
✗ View your account settings
The "can’t do" section builds trust. Users who see it are more likely to grant access because they feel informed, not steamrolled.
First-Party vs Third-Party Clients
Your own apps (the mobile app, the web dashboard) are first-party clients. Third-party clients are developers building on your platform.
Many providers skip the consent screen for first-party clients — this is acceptable if:
- The client is registered with a
first_party: trueflag in your client registry - The client is a confidential client (has a secret) that you control
- Users can still see and revoke the grant from their account settings
For third-party clients: consent is never skippable on the first authorization. Period. Subsequent authorizations with an unchanged scope can re-use a cached grant (remember consent), but show a "you’ve already authorized this" notice rather than silently skipping.
Storing and Revoking Grants
After the user consents, store the grant:
CREATE TABLE oauth_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
client_id TEXT NOT NULL,
scope TEXT NOT NULL, -- space-separated, canonical ordered
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used TIMESTAMPTZ,
UNIQUE (user_id, client_id)
);
Every user account needs a "Connected Apps" page that lists all active grants, when they were last used, and a single-click revoke button. No hoops, no confirmation dialogs for revocation. That’s RFC 7009 — token revocation — and it’s the user-facing version of it.
Gotcha: When a user revokes a grant, you need to revoke all associated refresh tokens and blocklist any active access tokens’
jtivalues. Just revoking the refresh token means the access token keeps working until it expires. For a 15-minute access token that’s acceptable in most threat models, but be explicit about it in your docs.
The "Remember This Decision" Checkbox
Don’t default it to checked. Don’t hide it. Let the user make an explicit choice about whether they want to skip consent on future authorizations from this client. If they check it, store the grant and skip the screen next time — but still validate that the requested scope hasn’t expanded.
Client Registration
Every client (web app, mobile app, CLI tool, third-party integration) must be registered before it can use your authorization server. At minimum, store:
CREATE TABLE oauth_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT, -- NULL for public clients
name TEXT NOT NULL,
description TEXT,
logo_url TEXT,
redirect_uris TEXT[] NOT NULL, -- exact URIs, no wildcards
allowed_scopes TEXT[] NOT NULL,
grant_types TEXT[] NOT NULL DEFAULT ARRAY['authorization_code'],
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_basic',
is_first_party BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
redirect_uris must be exact. When validating an authorization request, compare the provided redirect_uri against the registered list with a straight string equality check — no prefix matching, no domain-only matching, no wildcards.
Public clients (mobile apps, SPAs, CLIs) have no client_secret. They rely entirely on PKCE for security. Confidential clients (server-side web apps) have a secret and can use either client_secret_basic (HTTP Basic auth) or client_secret_post (in request body — less preferred).
Gotcha: Loopback redirect URIs (
http://127.0.0.1:PORT/callback) for native apps are a special case. RFC 8252 says you should allow any port on loopback — because the OS assigns a random available port at runtime. Your validation logic needs to strip the port before comparing loopback URIs. This is the one legitimate exception to exact-match redirect URI validation.
The Discovery Document
Publish /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/openid-configuration (if doing OIDC). Every modern client library uses these to auto-configure. Doing it manually is a maintenance nightmare.
{
"issuer": "https://auth.yourdomain.com",
"authorization_endpoint": "https://auth.yourdomain.com/oauth/authorize",
"token_endpoint": "https://auth.yourdomain.com/oauth/token",
"jwks_uri": "https://auth.yourdomain.com/.well-known/jwks.json",
"revocation_endpoint": "https://auth.yourdomain.com/oauth/revoke",
"introspection_endpoint": "https://auth.yourdomain.com/oauth/introspect",
"scopes_supported": ["openid", "profile", "email", "read:orders"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}
Note what’s not in response_types_supported: token (implicit flow). Don’t advertise it. Don’t implement it.
Docker Compose Skeleton
Here’s a minimal production-ready stack — your authorization server, a Postgres database for persistent state, and Redis for the token blocklist and short-lived code storage:
# docker-compose.yml
services:
authserver:
image: your-auth-server:latest
restart: unless-stopped
environment:
DATABASE_URL: "postgres://auth:${POSTGRES_PASSWORD}@db:5432/authdb?sslmode=require"
REDIS_URL: "redis://redis:6379/0"
ISSUER: "https://auth.yourdomain.com"
# Key rotation: mount keys as secrets, not env vars
SIGNING_KEY_PATH: "/run/secrets/rsa_private_key"
secrets:
- rsa_private_key
ports:
- "127.0.0.1:4000:4000" # bind to loopback; Nginx/Caddy handles TLS
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "https://cd-linux.club:4000/health"]
interval: 10s
timeout: 5s
retries: 3
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: authdb
POSTGRES_USER: auth
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- pg_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d # run migrations on init
healthcheck:
test: ["CMD-SHELL", "pg_isready -U auth -d authdb"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
volumes:
pg_data:
redis_data:
secrets:
rsa_private_key:
file: ./secrets/rsa_private_key.pem
Never put private signing keys in environment variables. They show up in docker inspect, process listings, and logs. Mount them as files or use Docker secrets.
Production Checklist
Before you ship this anywhere real:
- Key rotation plan: Your JWKS endpoint should serve 2–3 keys simultaneously during rotation. Old tokens signed with the previous key must remain valid until they expire. Never rotate keys by swapping — rotate by adding, then removing after the old key’s issued tokens have all expired.
- Rate limiting on the token endpoint: The token endpoint is a target for credential stuffing. Rate limit by client IP, client ID, and user ID. A client that fails PKCE verification 10 times in 5 minutes should get a temporary ban.
- Audit logging: Every authorization, every token issuance, every revocation. Include
client_id,user_id,scope,ip, anduser_agent. This is your forensics trail. - HTTPS, obviously: TLS 1.2 minimum, 1.3 preferred. HSTS with a long
max-age. No mixed content on your consent screen. - Content Security Policy on the consent screen: The consent screen is a high-value phishing target. Lock down your CSP so injected scripts can’t hijack the
?code=redirect. - Short-lived everything: Authorization codes expire in 60–120 seconds. Access tokens in 5–15 minutes. Only refresh tokens live long, and they’re hashed at rest.
The OAuth spec is large enough that people treat it like a black box and reach for a hosted service. That’s fine for most cases. But when you need to own the full authorization stack — for compliance, for cost, for control — the building blocks aren’t complicated. They just require precision. Get the token lifecycle right, design scopes as user-visible commitments not internal API labels, and build a consent screen that respects the user’s intelligence. The rest is plumbing.