Quick answer
The OAuth Authorization Code flow lets an application obtain delegated API access after a user approves access at an authorization server. The browser carries only a short-lived, single-use code back to the application. The application’s backend exchanges that code directly for tokens.
Use Authorization Code with PKCE, an exact registered redirect URI, and a fresh state value for every attempt. Validate state before exchanging the code, send the original PKCE verifier to the token endpoint, and keep client credentials and resulting tokens out of URLs and browser storage. OAuth delegates access; use OpenID Connect when the application also needs a standardized login identity.
Threat model
This flow is designed to keep access tokens out of the front-channel redirect and to let a user approve limited access without sharing a password with the client. PKCE binds an intercepted authorization code to the client instance that started the request. state binds the callback to the user’s pending browser session and helps stop login CSRF and response injection.
It does not make an unsafe redirect URI safe, protect a compromised application server, authorize every API operation, or validate an OpenID Connect identity token by itself. TLS remains mandatory. The resource server must still validate token issuer, audience, expiry, and scopes. A client must not accept a callback merely because it contains a syntactically valid code.
The relevant attackers include:
- a malicious site that tries to inject its own authorization response;
- an application that intercepts a code on the device;
- a user who changes callback parameters;
- an attacker who steals a client secret or refresh token;
- an operator who accidentally registers a broad redirect pattern.
Actors and data flow
There are four roles:
| Role | Responsibility |
|---|---|
| Resource owner | The user who approves access. |
| Client | The application requesting delegated access. |
| Authorization server | Authenticates the user, records consent, and issues codes and tokens. |
| Resource server | The API that accepts and validates access tokens. |
The safe sequence is:
browser -> client: start login or connection
client -> browser: redirect with state and PKCE code_challenge
browser -> authorization server: authenticate and approve
authorization server -> browser: redirect with code and state
browser -> client callback: code and state
client: validate pending session and state
client -> token endpoint: code, exact redirect_uri, code_verifier
token endpoint -> client: access token and optional refresh token
client -> resource server: Authorization: Bearer <access-token>
The authorization code is not an access token. It should expire quickly, be usable once, and be bound to the client, redirect URI, and PKCE challenge.
That separation also gives operators a clean audit boundary between user approval, code redemption, token issuance, and later API access.
Start the request safely
Generate two independent random values:
state, stored server-side with the pending browser session.- A PKCE
code_verifier, also stored with that attempt.
Derive code_challenge as base64url-encoded SHA-256 of the verifier and send code_challenge_method=S256. Include the exact client_id, registered redirect_uri, response_type=code, and minimal scopes.
GET /authorize?
response_type=code&
client_id=backend-study-client&
redirect_uri=https%3A%2F%2Fapp.example.test%2Flogin%2Foauth2%2Fcode%2Fstudy&
scope=profile%20articles.read&
state=RANDOM_STATE&
code_challenge=BASE64URL_SHA256_VERIFIER&
code_challenge_method=S256
Do not put an access token, client secret, or raw verifier in this URL. Authorization URLs appear in browser history, proxy logs, analytics, and referrer data.
Validate the callback before token exchange
The callback may contain code and state, or an OAuth error. Treat all input as untrusted.
- Load the pending attempt from the same protected browser session.
- Require a single
codeandstate. - Compare
stateusing a constant-time comparison where practical. - Delete or atomically mark the pending attempt used.
- Reject callbacks that are expired, missing, duplicated, or already used.
- Exchange the code only after validation.
Return a generic failure page to the browser and log a correlation ID, provider error code, and reason category. Do not log the code, verifier, client secret, tokens, or full callback URL.
Java with Spring Security
Spring Security’s OAuth2 Client support manages authorization requests, state, redirect handling, and the Authorization Code exchange. Prefer it over a custom controller.
spring:
security:
oauth2:
client:
registration:
study:
client-id: ${OAUTH_CLIENT_ID}
client-secret: ${OAUTH_CLIENT_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: profile,articles.read
provider:
study:
authorization-uri: https://identity.example.test/oauth2/authorize
token-uri: https://identity.example.test/oauth2/token
user-info-uri: https://identity.example.test/oauth2/userinfo
@Configuration
@EnableWebSecurity
class SecurityConfiguration {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(login -> login
.failureHandler((request, response, exception) -> {
request.getSession().removeAttribute("pending_destination");
response.sendRedirect("/login?error=oauth");
}))
.oauth2Client(Customizer.withDefaults())
.build();
}
}
Modern Spring Security clients use Authorization Code for OAuth login and can apply PKCE. Confirm the provider metadata and framework version rather than assuming a provider accepts insecure fallback behavior. Store production credentials in a secret manager, never in application.yml.
Access an authorized client without copying tokens into a template:
@GetMapping("/profile")
Map<String, Object> profile(
@RegisteredOAuth2AuthorizedClient("study") OAuth2AuthorizedClient client
) {
return Map.of(
"connected", true,
"expiresAt", client.getAccessToken().getExpiresAt()
);
}
This endpoint deliberately does not return the token. Server-side API code can attach it through an OAuth-aware WebClient.
Node.js with Express
The example below shows the invariants explicitly. Production code should use a maintained OAuth/OIDC client library for provider discovery, token response validation, and refresh behavior.
import crypto from "node:crypto";
import express from "express";
const app = express();
const pending = new Map(); // Replace with expiring shared storage.
const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest();
app.get("/connect/study", (req, res) => {
const attemptId = base64url(crypto.randomBytes(24));
const state = base64url(crypto.randomBytes(32));
const verifier = base64url(crypto.randomBytes(32));
pending.set(attemptId, {
state,
verifier,
expiresAt: Date.now() + 5 * 60_000
});
res.cookie("oauth_attempt", attemptId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 5 * 60_000
});
const url = new URL("https://identity.example.test/oauth2/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: process.env.OAUTH_CLIENT_ID,
redirect_uri: "https://app.example.test/oauth/callback/study",
scope: "profile articles.read",
state,
code_challenge: base64url(sha256(verifier)),
code_challenge_method: "S256"
});
res.redirect(url.toString());
});
The callback consumes the attempt before calling the provider:
app.get("/oauth/callback/study", async (req, res) => {
const attemptId = req.cookies.oauth_attempt;
const attempt = pending.get(attemptId);
pending.delete(attemptId);
res.clearCookie("oauth_attempt");
if (!attempt || attempt.expiresAt < Date.now()) {
return res.redirect("/login?error=oauth");
}
const received = Buffer.from(String(req.query.state ?? ""));
const expected = Buffer.from(attempt.state);
if (received.length !== expected.length ||
!crypto.timingSafeEqual(received, expected) ||
typeof req.query.code !== "string") {
return res.redirect("/login?error=oauth");
}
const tokenResponse = await fetch("https://identity.example.test/oauth2/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: req.query.code,
redirect_uri: "https://app.example.test/oauth/callback/study",
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
code_verifier: attempt.verifier
})
});
if (!tokenResponse.ok) return res.redirect("/login?error=oauth");
const tokens = await tokenResponse.json();
await saveTokensEncrypted(req.user.id, tokens);
return res.redirect("/settings/connections");
});
Use an atomic get-and-delete operation in Redis or a database instead of a process-local Map when multiple instances serve callbacks.
Failure handling
Map provider failures into stable application outcomes:
| Condition | Response |
|---|---|
| User denies consent | Explain that connection was canceled; offer retry. |
Missing or wrong state | Stop; do not exchange the code. |
| Expired pending attempt | Stop and start a new flow. |
| Token endpoint rejects code | Show generic failure; log sanitized provider code. |
| Provider timeout | Do not blindly reuse the code; allow a fresh attempt. |
| Invalid token response | Reject it; never accept partial fields as success. |
Never redirect to a caller-supplied post-login URL without allowlisting it. Store an internal destination identifier with the pending attempt instead.
Tests that matter
Automate these cases:
- a valid callback consumes its pending attempt and exchanges once;
- a changed, absent, or repeated
stateis rejected before network access; - an expired attempt is rejected;
- a reused callback cannot exchange twice;
- the token request includes the original verifier and exact redirect URI;
- a provider error never exposes client credentials or token response bodies;
- concurrent callbacks cannot both consume the same attempt;
- an unapproved destination cannot become an open redirect.
For Spring, use MockMvc plus a mocked authorized-client service. For Node.js, inject the token-exchange function and an atomic pending-attempt repository so tests assert that invalid callbacks make zero token requests.
Common mistakes
Using Authorization Code without PKCE. A confidential client secret does not replace per-attempt code binding.
Treating state as optional. PKCE and state address different threats.
Broad redirect URI patterns. Register exact HTTPS callbacks. Do not accept arbitrary subdomains or query-controlled destinations.
Putting tokens in the browser URL. Tokens leak through history, logs, and referrers.
Using OAuth alone as login. OAuth grants API access. OpenID Connect defines identity assertions and ID token validation.
Logging sensitive callback data. Log correlation metadata, never codes, verifiers, or tokens.
Implementing provider validation from scratch. Prefer a maintained library and issuer metadata.
Practical checklist
- Use Authorization Code with PKCE
S256. - Generate a fresh high-entropy
stateand verifier per attempt. - Bind both values to an expiring browser session.
- Register exact HTTPS redirect URIs.
- Validate and atomically consume
statebefore exchange. - Send the original verifier and exact redirect URI to the token endpoint.
- Request only necessary scopes.
- Store client credentials in a secret manager.
- Keep tokens server-side and encrypted where required.
- Sanitize logs and browser errors.
- Test replay, expiry, provider denial, and open redirects.
- Validate access tokens at every resource server.
Frequently asked questions
Is PKCE only for mobile applications?
No. Current OAuth security guidance recommends PKCE broadly, including web clients. It adds per-request proof even when a backend also has a client secret.
Is state still needed when PKCE is enabled?
Yes. PKCE binds the code to a verifier. state binds the authorization response to the browser session and application attempt.
Does Authorization Code authenticate the user?
OAuth itself delegates access. Use OpenID Connect and validate the ID token when standardized authentication is required.
Where should refresh tokens be stored?
Keep them in protected server-side storage, encrypt them when the risk model requires it, restrict access, rotate them when supported, and never expose them to client-side JavaScript.
Should a single-page application keep tokens in localStorage?
Avoid long-lived sensitive tokens in JavaScript-accessible storage. A backend-for-frontend with secure, HttpOnly cookies often gives a stronger containment boundary.
Related reading
- Continue through the Authentication learning path and browse all topic clusters.
- Compare delegated access and identity in OAuth 2.0 vs OpenID Connect.
- Use OAuth Client Credentials Flow Explained for service identity without an end user.
- Limit grants with OAuth Scopes Explained.
- Model the flow in OpenAPI Security Schemes Explained.