Implement an OAuth2 Authorization Code Server
A complete OAuth2 authorization-code server with PKCE, consent, scoped access tokens, refresh rotation, and revocation, plus tests.
Project goal
Implement the OAuth2 authorization-code flow with PKCE so a public client can obtain scoped access tokens and rotate refresh tokens without exposing secrets.
Final features
- Authorization code issue
- PKCE verification
- Scope-limited access token
- Refresh rotation
- Revocation
Architecture
Two endpoints (/authorize and /token) plus a JWKS endpoint. The authorize endpoint validates client and redirect uri, records consent, and issues a single-use code bound to the PKCE challenge. The token endpoint exchanges the code, verifies the verifier, and returns a short-lived access token and a rotating refresh token.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+ for codes and tokens
- A test client that can complete a redirect
Dependencies
- jose ^5.9.6 — JWT signing and JWKS
- express ^4.21.0 — HTTP endpoints
- pg ^8.12.0 — Persistence for codes and refresh tokens
Directory structure
oauth2-authz-server/
├── src/authorize.ts
├── src/token.ts
├── src/pkce.ts
├── src/keys.ts
├── src/store.ts
├── tests/flow.test.ts
└── package.json
Phases
1. Authorization code issue
Validate client_id, redirect_uri, and PKCE, then store a single-use code.
await store.saveCode({ code, clientId, redirectUri, challenge, ttl: 60 });
Expected behavior: Binding the code to the verifier prevents interception by a redirect hijacker.
2. PKCE verification
Hash the presented verifier and compare it to the stored S256 challenge.
const { base64url } = await import('jose');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
assert(base64url.encode(digest) === storedChallenge);
Expected behavior: A stolen code is useless without the original verifier.
3. Scope-limited access token
Sign a short-lived JWT whose scope claim is the intersection of requested and granted.
const jwt = await new SignJWT({ scope: granted.join(' ') })
.setSubject(userId).setExpirationTime('5m')
.setProtectedHeader({ alg: 'RS256', kid }).sign(privateKey);
Expected behavior: Tokens carry only the granted scope, limiting the blast radius of a leak.
4. Refresh rotation
Issue a new refresh token on each use and revoke the previous one atomically.
const ok = await store.rotateRefresh(oldToken, newToken);
if (!ok) throw new Error('refresh token reuse detected');
Expected behavior: Rotation detects replay: a reused token signals theft.
5. Revocation
Expose /revoke and delete the token, its family, and cached access tokens.
await store.revokeFamily(refreshToken.familyId);
Expected behavior: Users and clients can end a session that may be compromised.
Testing
Drive the full flow with a real redirect: request a code, exchange it, call a protected resource, rotate the refresh token, and assert that reusing the old refresh token fails.
Troubleshooting
Token endpoint returns invalid_grant for a good code
The code was already redeemed or expired; codes are single-use with a short TTL.
Redirect URI mismatch
Comparing normalized URLs must ignore trailing slashes and default ports; use exact string match against registered values.
Deployment
Run on HTTPS only, publish the JWKS at a stable URL, and store signing keys in a KMS or secret manager. Keep access tokens short-lived and support key rotation by publishing the new key before signing with it.
Known limitations
- The server supports the authorization-code flow only, not implicit or client-credentials.
- Consent is recorded but no admin UI for grants is included.
- Dynamic client registration is out of scope.
Project overview
The authorization code flow is the one part of OAuth2 that a public client can use without a secret, and it is also the part that is easiest to implement subtly wrong. This build implements it with PKCE, scoped access tokens, and refresh rotation.
You will expose an authorization endpoint and a token endpoint plus a JWKS, validate the client and the redirect URI, record consent, issue a single-use code bound to the PKCE challenge, and exchange that code for a short-lived access token and a rotating refresh token.
PKCE is what makes the flow safe for a public client: the code is bound to a verifier the client generated, so an intercepted code is useless without the verifier that never left the client.
Every validation here has a reason: an unchecked redirect URI turns the server into an open redirector, and a reusable code turns a single authorization into an unlimited one.
Each phase adds one guarantee: code issuance, PKCE verification, scope-limited tokens, refresh rotation, and revocation, so the flow is safe at every stage.
It is written for developers who have integrated a third-party authorization server and want to understand what actually happens on each redirect. The implementation keeps every validation visible so the reason for each check is legible rather than hidden inside a library default.
What you will build
The server provides five capabilities, each exercised by a test that drives a real redirect:
- Authorization code issue
- PKCE verification
- Scope-limited access token
- Refresh rotation
- Revocation
The authorization endpoint issues a single-use code; PKCE binds the code to a verifier; the token endpoint returns a scoped, short-lived access token; refresh tokens rotate on use; and revocation ends a grant on demand.
The design treats the redirect URI as a registered value rather than a parameter, because an unregistered URI is the mechanism by which an attacker receives a code meant for someone else.
Architecture and data model
Two endpoints (/authorize and /token) plus a JWKS endpoint. The authorize endpoint validates client and redirect uri, records consent, and issues a single-use code bound to the PKCE challenge. The token endpoint exchanges the code, verifies the verifier, and returns a short-lived access token and a rotating refresh token.
The authorization endpoint validates the client, the requested scopes, and the redirect URI against registration, records consent, and issues a code bound to the PKCE challenge and the client.
The token endpoint exchanges the code once, verifies the PKCE verifier against the challenge, and returns a short-lived access token plus a rotating refresh token. The code is marked used so a second exchange fails.
Access tokens are signed JWTs whose scopes are the intersection of the client’s request and the user’s consent, so a client cannot receive more authority than either party intended.
Signing keys are published through a JWKS at a stable URL, so resource servers can verify tokens without calling the authorization server on every request.
The code’s stored bindings are the security model: client, redirect URI, scopes, and PKCE challenge are all fixed at issuance, so a code presented with any of them changed is rejected. That is why the token endpoint re-validates rather than trusting the authorization endpoint’s earlier decision.
Access token scopes are checked by resource servers during normal request authorization, so the authorization server’s job ends at issuing a token that accurately reflects consent; making the scope set explicit is what lets that check reduce to a set comparison.
Prerequisites and dependencies
You need a key pair set for signing, a store for clients and grants, and a stable HTTPS endpoint for the JWKS and the redirect handling.
- Node.js 20 LTS or later
- PostgreSQL 15+ for codes and tokens
- A test client that can complete a redirect
The dependencies cover the HTTP endpoints, the signing library, and the grant store:
jose(^5.9.6) — JWT signing and JWKSexpress(^4.21.0) — HTTP endpointspg(^8.12.0) — Persistence for codes and refresh tokens
pnpm init
pnpm add jose express pg
pnpm add -D typescript tsx vitest
Keep signing keys in a KMS or secret manager and rotate them by publishing the new key before signing with it. Register exact redirect URIs rather than wildcards, because a wildcard registration reintroduces the open-redirect risk the flow exists to avoid.
Directory structure
Separate the client registry, the authorization endpoint, the PKCE verification, the token issuance, and the key publication so each is testable:
oauth2-authz-server/
├── src/authorize.ts
├── src/token.ts
├── src/pkce.ts
├── src/keys.ts
├── src/store.ts
├── tests/flow.test.ts
└── package.json
Phase 1: Authorization code issue
Validate client_id, redirect_uri, and PKCE, then store a single-use code.
await store.saveCode({ code, clientId, redirectUri, challenge, ttl: 60 });
Binding the code to the verifier prevents interception by a redirect hijacker.
Issue a single-use code that is bound to the client, the redirect URI, the scopes, and the PKCE challenge. Binding every one of these is what prevents a code from being redirected or reused for a different client.
Phase 2: PKCE verification
Hash the presented verifier and compare it to the stored S256 challenge.
const { base64url } = await import('jose');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
assert(base64url.encode(digest) === storedChallenge);
A stolen code is useless without the original verifier.
Verify the PKCE verifier against the stored challenge at the token endpoint, and reject the code if it does not match. This is the check that makes an intercepted code useless to anyone but the original client.
Phase 3: Scope-limited access token
Sign a short-lived JWT whose scope claim is the intersection of requested and granted.
const jwt = await new SignJWT({ scope: granted.join(' ') })
.setSubject(userId).setExpirationTime('5m')
.setProtectedHeader({ alg: 'RS256', kid }).sign(privateKey);
Tokens carry only the granted scope, limiting the blast radius of a leak.
Issue a scoped access token that is the intersection of the requested scopes and the granted consent, and keep its lifetime short so a leaked token has a small window.
Phase 4: Refresh rotation
Issue a new refresh token on each use and revoke the previous one atomically.
const ok = await store.rotateRefresh(oldToken, newToken);
if (!ok) throw new Error('refresh token reuse detected');
Rotation detects replay: a reused token signals theft.
Rotate the refresh token on every use and invalidate the previous one, so a stolen refresh token becomes a detectable event rather than a permanent second grant.
Phase 5: Revocation
Expose /revoke and delete the token, its family, and cached access tokens.
await store.revokeFamily(refreshToken.familyId);
Users and clients can end a session that may be compromised.
Support revocation of a grant so a user can end access, and make the resource server able to learn of the revocation within the access token’s remaining lifetime.
Testing
Drive the full flow with a real redirect: request a code, exchange it, call a protected resource, rotate the refresh token, and assert that reusing the old refresh token fails.
Assert that an unregistered redirect URI is rejected, because this is the validation that stands between the flow and an attacker who receives another user’s code.
Assert that a second exchange of the same code fails, which pins the single-use property as behavior rather than as an intention.
Assert that the returned scopes never exceed the consent, because a token with extra scope is an authorization bug that no other test would catch.
Cover the failure paths as carefully as the happy path, because the happy path in OAuth2 is rarely where the bug is; a test that only completes the flow will pass while an unregistered redirect URI is silently accepted, which is the vulnerability that matters.
Assert too that a code presented to a different client is rejected, because the client binding is a separate protection from the PKCE verifier and both must be exercised.
Because the failure paths carry the security weight here, the suite asserts the exact reason for each rejection rather than merely that a request failed, so a regression that weakens the redirect or PKCE checks is caught by name rather than by a generic error.
pnpm vitest run
Deployment and operations
Run on HTTPS only, publish the JWKS at a stable URL, and store signing keys in a KMS or secret manager. Keep access tokens short-lived and support key rotation by publishing the new key before signing with it.
Run on HTTPS only and publish the JWKS at a stable URL, because a JWKS that moves breaks every resource server’s verification at once.
Keep access tokens short-lived and publish the new signing key before signing with it, so key rotation does not invalidate live tokens.
Monitor authorization failures by reason, since a spike in a particular reason usually points at a client misconfiguration rather than at an attack.
Log grants and revocations with the client id and scopes but not the tokens themselves, so access can be audited without recording credentials.
Coordinate the signing key rotation with the token lifetime, since a key retired too soon invalidates access tokens that resource servers still hold, and the symptom appears as scattered verification failures rather than as one clean error.
Security considerations
Require PKCE for public clients, pin exact redirect URIs, enforce state on the client, and never log tokens or codes. Rate-limit the token endpoint and reject unknown clients before any state change.
Validate the redirect URI against exact registration values, and reject a mismatch rather than falling back to a default, because the fallback is the vulnerability.
Require PKCE for public clients and store the challenge with the code, so a code alone is never sufficient to obtain a token.
Sign access tokens with a key held outside the application host where possible, and never expose the private key through the JWKS.
Consent must be explicit and recorded with the granted scopes, because silent consent turns a user’s authorization into a client’s entitlement.
Never allow a redirect URI that was not registered exactly, including a subdomain or a path suffix, because a permissive comparison is how an attacker receives a code meant for a legitimate client.
Observability
Log one event per token issue, refresh, and revoke with client id and outcome but no token values. Alert on a spike in refresh-reuse failures, which indicates token theft.
Track authorization requests, token exchanges, refreshes, and revocations separately, because a change in one of these flows has a different meaning than a change in another.
Chart token exchange failures by reason, since a rising invalid-grant rate indicates clients with a wrong verifier or a reused code.
Measure access token verification failures at the resource servers, because a spike after a deploy usually means a key rotation window is too tight.
Alert on consent rate changes, since a sudden jump can indicate a client requesting broader scopes than its users expect.
Track the scopes actually granted versus the scopes requested, because a widening gap means clients are asking for more than their users consent to, which is a product and trust problem as much as a security one.
Measure the time from authorization to token exchange, because a large gap often indicates a client that is dropping codes rather than a server problem.
Troubleshooting
Token endpoint returns invalid_grant for a good code. The code was already redeemed or expired; codes are single-use with a short TTL.
Redirect URI mismatch. Comparing normalized URLs must ignore trailing slashes and default ports; use exact string match against registered values.
If a client cannot exchange a code, check the PKCE verifier first, because a mismatch is the most common cause and is deliberate rather than a bug.
If every token fails to verify after a rotation, check the JWKS publication, since a key published too late or not at all invalidates live tokens.
Limitations and next steps
- The server supports the authorization-code flow only, not implicit or client-credentials.
- Consent is recorded but no admin UI for grants is included.
- Dynamic client registration is out of scope.
The server implements the authorization code flow with PKCE and refresh rotation, not the client credentials or implicit flows, which are omitted deliberately.
There is no dynamic client registration, so clients are registered administratively rather than self-service.
Consent is a simple grant without a management UI, so a user cannot review or revoke individual grants through the server itself beyond the revocation endpoint.
There is no OpenID Connect identity layer, so the server issues access tokens rather than id tokens and profile claims.
The server does not implement token introspection or a revocation list that resource servers pull, so revocation takes effect within the access token lifetime rather than immediately; a push channel to resource servers would tighten that window.