OAuth 2.0 Authorization Code Flow with PKCE for SPA and Mobile Apps
For developers wiring login into a single-page or mobile app without a client secret. This walks you through generating PKCE values, building the authorize URL, exchanging the code, validating tokens, and diagnosing the redirect and verifier errors that usually block rollout.
TL;DR — For SPAs and mobile apps, use OAuth 2.0 authorization code flow with PKCE, not the implicit flow and not a shipped client secret. The most common failure is sending a different
code_verifierto/tokenthan the one used to derive thecode_challengefor/authorize, or mismatching the registered redirect URI by one character. Reading time: ~5 min
Goal
When you finish, your SPA or mobile app can send the user to the authorization server, receive an authorization code on a registered redirect URI, exchange that code with PKCE at the token endpoint, and call your API with a valid access token.
Prerequisites
- An OAuth 2.0 / OpenID Connect provider and permission to create or edit an app registration
- The exact values for your provider's endpoints: authorization endpoint, token endpoint, and JWKS endpoint
- A registered client ID for a public client; no client secret required for SPA/mobile
- One exact redirect URI per app target, for example:
- SPA:
http://localhost:5173/callback - Mobile custom scheme:
com.example.app:/oauth/callback - Mobile loopback:
http://127.0.0.1:8787/callback
- SPA:
- Scopes to request, for example:
openid profile email offline_access api.read node >= 20— check with:
node --version
curl >= 8— check with:
curl --version | head -n 1
openssl— check with:
openssl version
Steps
View as text
[App] ---- 1. build authorize URL ----> [Authorization Server] [App] <---- 2. redirect to login ------ [Authorization Server] [User Browser] | v [Authorization Server] ---- 3. redirect with code ----> [Redirect URI] [App] ---- 4. POST code+verifier ------> [Token Endpoint] [App] <---- 5. access/id/refresh token - [Token Endpoint] [App] ---- 6. Bearer access token -----> [API]
Step 1: Register the app as a public client
In your provider's dashboard, create or edit the app registration using these literal values where the UI asks for them:
- Application type:
Single-page applicationorNative/Mobile - Client authentication:
NoneorPublic client - Redirect URIs:
http://localhost:5173/callbackcom.example.app:/oauth/callbackhttp://127.0.0.1:8787/callback
- Grant types:
Authorization Code - PKCE required:
Yesif your provider exposes that switch - Allowed scopes:
openid profile email offline_access api.read
What you should see: the app registration shows a client_id, your redirect URIs exactly as entered, and no requirement to send a client secret from the SPA/mobile app.
Step 2: Generate a PKCE code verifier and code challenge
Run these commands exactly. Keep the CODE_VERIFIER value for the later token exchange.
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=+/\n' | cut -c1-64)
printf '%s' "$CODE_VERIFIER"
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | openssl base64 -A | tr '+/' '-_' | tr -d '=')
printf '%s\n' "$CODE_CHALLENGE"
What you should see: a verifier 43-128 characters long and a challenge that is URL-safe base64 without = padding.
Step 3: Build the authorization URL and start login
Replace the hostnames and values with your provider's real endpoints and your client ID.
AUTH_ENDPOINT="https://id.example.com/oauth2/authorize"
CLIENT_ID="YOUR_CLIENT_ID"
REDIRECT_URI="http://localhost:5173/callback"
SCOPE="openid profile email offline_access api.read"
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
echo "${AUTH_ENDPOINT}?response_type=code&client_id=${CLIENT_ID}&redirect_uri=$(python3 - <<'PY'
import urllib.parse, os
print(urllib.parse.quote(os.environ['REDIRECT_URI'], safe=''))
PY
)&scope=$(python3 - <<'PY'
import urllib.parse, os
print(urllib.parse.quote(os.environ['SCOPE'], safe=''))
PY
)&state=${STATE}&nonce=${NONCE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"
Open the printed URL in the browser.
What you should see: the provider's login page, then a redirect to http://localhost:5173/callback?code=...&state=... after successful authentication.
Step 4: Confirm the redirect URI is exact before debugging anything else
If the browser does not land on your callback, test the redirect URI itself.
curl -I "http://localhost:5173/callback"
Typical success shape for a local dev server:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Typical failure shape when the app is not serving that route:
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
What you should see: either a 200 OK for the callback route or a deliberate SPA fallback that still serves your app shell.
Step 5: Exchange the authorization code for tokens
Copy the code value from the callback URL and send it with the same CODE_VERIFIER from Step 2.
TOKEN_ENDPOINT="https://id.example.com/oauth2/token"
AUTH_CODE="PASTE_THE_CODE_FROM_CALLBACK"
curl -sS -X POST "$TOKEN_ENDPOINT" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "code=$AUTH_CODE" \
--data-urlencode "redirect_uri=$REDIRECT_URI" \
--data-urlencode "code_verifier=$CODE_VERIFIER"
Typical success shape:
{"access_token":"eyJ...","id_token":"eyJ...","refresh_token":"def502...","token_type":"Bearer","expires_in":3600,"scope":"openid profile email api.read"}
Typical PKCE failure shape:
{"error":"invalid_grant","error_description":"PKCE verification failed"}
What you should see: JSON with at least access_token, token_type=Bearer, and usually id_token for OIDC.
Step 6: Call your API with the access token
Use the access token exactly as returned.
ACCESS_TOKEN="PASTE_ACCESS_TOKEN"
curl -sS https://api.example.com/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
What you should see: a 200 response from your API for the authenticated user, not 401 Unauthorized.
Step 7: Validate tokens in the app/backend correctly
For a SPA, do not validate signatures manually in browser code unless you have to; prefer using a maintained OIDC client library. For your API/backend, validate against the provider's JWKS and expected issuer/audience. To inspect the JWT payload during debugging only:
python3 - <<'PY'
import base64, json, os
jwt = os.environ['ACCESS_TOKEN'].split('.')
payload = jwt[1] + '=' * (-len(jwt[1]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY
What you should see: claims including iss, aud, exp, and scopes/permissions that match your API expectation.
Verify it works
Run the full flow once and verify all four checks:
curl -I "http://localhost:5173/callback"
Expected: a reachable callback route, usually HTTP/1.1 200 OK.
Open the authorize URL from Step 3.
Expected: browser lands on http://localhost:5173/callback?code=...&state=... with the same state you generated.
Run the token exchange from Step 5.
Expected: JSON containing access_token and no error field.
curl -i https://api.example.com/me -H "Authorization: Bearer $ACCESS_TOKEN"
Expected shape:
HTTP/2 200
content-type: application/json
Common pitfalls
Redirect URI differs by one character
Mistake: registered http://localhost:5173/callback/ but sent http://localhost:5173/callback, or used localhost in one place and 127.0.0.1 in the other.
Symptom: authorization server rejects the request or token exchange returns invalid_grant / redirect_uri mismatch.
Fix: copy the redirect URI from the app registration and use that exact string in both /authorize and /token.
New code verifier generated before token exchange
Mistake: app reloads and regenerates PKCE values after redirect, then posts a different code_verifier than the one used for code_challenge.
Symptom: token endpoint returns 400 with invalid_grant, PKCE verification failed, or code_verifier invalid.
Fix: persist code_verifier and state across the redirect in memory that survives navigation, session storage, or secure platform storage.
Using a client secret in a SPA/mobile app
Mistake: sending client_secret from browser or mobile code because the provider UI offered one.
Symptom: false sense of security, leaked secret in shipped code, or provider rejects the app type.
Fix: register as a public client and send only client_id; keep secrets only on confidential backends.
Callback route returns 404 in local dev
Mistake: dev server is not configured to serve the SPA shell on /callback.
Symptom: login succeeds at the provider, then browser lands on a blank page or 404 Not Found before your app can read code and state.
Fix: add a route for /callback or enable SPA history fallback so the app shell loads there.
State or nonce not checked
Mistake: app reads code from the callback but ignores state, or ignores nonce when validating ID tokens.
Symptom: intermittent login bugs, replay confusion across tabs, or failed OIDC validation in libraries.
Fix: store generated state and nonce, then compare them exactly on callback/token processing.
Requesting refresh tokens without the right scope/policy
Mistake: expecting refresh_token but not requesting offline_access, or the provider disallows refresh tokens for public clients.
Symptom: token response contains no refresh_token even though login worked.
Fix: request offline_access and enable refresh tokens for public clients in the provider if supported; otherwise use silent reauth or backend token handling.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI