Okta Authorization Code Flow with PKCE: A Working Configuration for SPAs and Native Apps
How Okta's Authorization Code flow with PKCE protects public clients, with a working verifier/challenge setup, token exchange, and the mistakes that break it.
18 Apr 2026, 08:09 UTC

The short answer
If you are building a single-page application or a native mobile/desktop app against Okta, use the Authorization Code flow with PKCE (Proof Key for Code Exchange). It is the only flow Okta recommends for public clients — apps that cannot keep a client secret — because it binds the authorization code to the exact device and session that requested it. Even if an attacker intercepts the code from a redirect, they cannot exchange it without the secret verifier that never left the client.
The mechanism is simple: the client generates a random secret, sends only a hash of it to Okta during login, and presents the original secret when trading the returned code for tokens. Okta re-hashes and compares. Mismatch means rejection.
How the PKCE binding works
PKCE adds two parameters to the standard Authorization Code flow:
- code_verifier: a high-entropy random string (43–128 characters) generated by the client and kept in memory or secure storage. It is never sent during the login redirect.
- code_challenge: the Base64URL-encoded SHA-256 hash of the verifier, sent to Okta in the authorization request.
Okta stores the challenge alongside the authorization code it issues. At the token endpoint, the client sends the code plus the original verifier. Okta hashes the verifier and compares it to the stored challenge. An attacker holding only the code has nothing to hash-match against.
A worked configuration
Assume an Okta org at https://dev-123456.okta.com and an OIDC app of type "Single-Page Application" with redirect URI http://localhost:3000/callback. The examples below run anywhere Node.js is available; no Okta admin rights are needed beyond creating the app registration.
1. Generate the verifier and challenge
const crypto = require('crypto');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
console.log('verifier:', verifier); // keep this in memory
console.log('challenge:', challenge); // send this to Okta2. Send the user to Okta
Build the authorization URL and redirect the browser:
https://dev-123456.okta.com/oauth2/default/v1/authorize
?response_type=code
&client_id=0oaEXAMPLECLIENTID
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
&scope=openid%20profile%20email%20offline_access
&state=RANDOM_CSRF_VALUE
&code_challenge=CHALLENGE_FROM_STEP_1
&code_challenge_method=S256Two things to check in this URL before shipping: code_challenge must be present (if it is missing, Okta will still issue a code but PKCE protection is gone), and state must be a random value you verify on return to prevent cross-site request forgery on the callback.
3. Exchange the code for tokens
After Okta redirects back with ?code=...&state=..., verify state matches what you sent, then POST to the token endpoint:
curl -X POST https://dev-123456.okta.com/oauth2/default/v1/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'client_id=0oaEXAMPLECLIENTID' \
-d 'redirect_uri=http://localhost:3000/callback' \
-d 'code=CODE_FROM_REDIRECT' \
-d 'code_verifier=VERIFIER_FROM_STEP_1'Note there is no client_secret — public clients authenticate via PKCE instead. The redirect_uri here must exactly match the one in the authorization request, character for character, or Okta rejects the exchange.
4. Verify the tokens
The response contains an access token, an ID token, and (because offline_access was requested) a refresh token. Before trusting the ID token, validate its signature against Okta's JWKS at /oauth2/default/v1/keys and confirm the iss claim equals your authorization server URL exactly. Libraries such as okta-jwt-verifier or any OIDC client library do this for you; do not decode-and-trust without signature validation.
Limits and common mistakes
- Lost verifier between steps. The most frequent failure: the SPA stores the verifier in memory, the callback runs in a fresh page load, and the verifier is gone. Store it in
sessionStorage(notlocalStorage, to limit cross-tab exposure) before redirecting, and clear it after exchange. Symptom: Okta returns aninvalid_granterror at the token endpoint. - Redirect URI drift. A trailing slash, scheme change (http vs https), or port difference between the authorize request and the token request causes rejection. Define one constant and reuse it.
- Reusing a verifier. Generate a fresh verifier per authorization attempt. Reuse weakens the binding and some servers flag it.
- Plaintext challenge method. Always use
S256. Theplainmethod sends the verifier itself as the challenge, defeating the point if the authorize request is observed. - Refresh token expiry surprises. Refresh token rotation and lifetime are governed by your Okta authorization server policy. If the user session or refresh token has expired, the refresh call fails and you must send the user through a fresh PKCE flow. Handle the error path explicitly rather than assuming silent renewal always succeeds.
Verifying the setup end to end
- Open the browser's network log and confirm the authorize request contains
code_challengeandcode_challenge_method=S256. - Reproduce the hash locally: SHA-256 your stored verifier, Base64URL-encode it, and confirm it equals the challenge you sent. If it doesn't, your encoding is wrong (a common bug is forgetting to strip padding or using standard Base64 instead of Base64URL).
- Deliberately send a wrong
code_verifierin the token exchange. Okta should reject it with an invalid grant error. If the exchange succeeds with a wrong verifier, PKCE is not actually enforced and you should stop and investigate before shipping.
Behavior described here reflects Okta's long-standing OAuth 2.0 / OIDC implementation (org authorization server and custom authorization servers under /oauth2/default). If you use a custom authorization server, substitute its ID in the endpoints. Confirm exact error codes and token lifetimes against your own tenant's configuration, since policies vary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.