← Complete Projects

阅读中文版本

Implement an OAuth2 Authorization Code Server

A complete OAuth2 authorization-code server with PKCE, consent, scoped access tokens, refresh rotation, and revocation, plus tests.

advanced~6 hoauth2securitynodeauthapi

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 JWKS
  • express (^4.21.0) — HTTP endpoints
  • pg (^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.