Using PKCE to Secure Public Clients in OAuth 2.0
Learn how PKCE lets SPAs and mobile apps use the OAuth 2.0 authorization code flow without storing a client secret, and see a concrete curl‑based example.
03 Sept 2025, 13:27 UTC

The problem: public clients can’t keep a secret
When you build a JavaScript single‑page application (SPA) or a native mobile app, the client runs in an environment where anyone can extract the code. Storing a client secret there would defeat the purpose of a secret, yet the OAuth 2.0 authorization code flow traditionally relies on that secret to prevent an attacker who steals the authorization code from exchanging it for tokens.
If the authorization code is intercepted (for example, via a network sniff or a malicious redirect), the attacker could trade it for an access token and act on behalf of the user. We need a way to bind the code exchange to the original client without storing any secret on the client side.
How PKCE solves it
Proof Key for Code Exchange (PKCE) replaces the static client secret with a dynamically generated code_verifier and a derived code_challenge. The flow works as follows:
- The client creates a high‑entropy random string (the
code_verifier, 43‑128 characters). - It derives a
code_challengeby hashing the verifier with SHA‑256 and base64url‑encoding the result (code_challenge_method=S256). For debugging you may also use the plain method, but S256 is the only method that provides real security. - The authorization request includes
code_challengeandcode_challenge_method. The authorization server stores the challenge. - After the user authenticates and is redirected back, the client sends the original
code_verifiertogether with the authorization code to the token endpoint. - The server hashes the received verifier and compares it to the stored challenge. Only the client that knows the verifier can succeed.
Because the verifier never leaves the client, an attacker who only sees the authorization code cannot complete the token request.
Worked example: exchanging code with PKCE (curl)
Below is a concrete, non‑tested sequence showing the HTTP messages you would see. Replace the placeholders with values from your own registration.
# 1. Generate verifier and challenge (example using openssl)
VERIFIER=$(openssl rand -base64 32 | tr -d '\n' | cut -c1-43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
# 2. Authorization request (run in a browser or via curl for demonstration)
curl -v "https://auth.example.com/authorize?"\
"response_type=code"\
"&client_id=YOUR_CLIENT_ID"\
"&redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fcallback"\
"&scope=openid%20profile"\
"&code_challenge=$CHALLENGE"\
"&code_challenge_method=S256"
# 3. After the user logs in, you receive a redirect containing ?code=AUTH_CODE
# 4. Token exchange (include the verifier)
curl -X POST "https://auth.example.com/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fcallback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code_verifier=$VERIFIER"
If you change even a single character of $VERIFIER in step 4, the server will respond with error=invalid_grant, proving the binding works.
Limitations and what you still need to do
PKCE protects the authorization code, but it does not solve every threat:
- Token leakage: The access token (and refresh token, if issued) must still be stored safely. In an SPA, keeping it in memory or using short‑lived tokens with silent renewal reduces risk; localStorage is vulnerable to XSS.
- Network security: Both the authorization and token endpoints must be reached over TLS. Without it, an attacker could sniff the verifier or tokens.
- Refresh‑token rotation: PKCE says nothing about refresh tokens. If your authorization server issues refresh tokens, enable rotation so that re‑using a stolen refresh token fails.
- Server support: The authorization server must accept
S256challenges and verify them. Older implementations may only support the plain method, which offers no real protection.
Practical verification steps you can run in a test environment:
- Capture the authorization request and confirm it contains
code_challengeandcode_challenge_method=S256. - Attempt to exchange the authorization code with an incorrect verifier; expect
invalid_grant. - Obtain a refresh token, use it to get a new access token, then try to reuse the original refresh token. A server with rotation will reject it.
Actionable closing
If you are building a public client, start by adding PKCE to your authorization code flow. Use a well‑maintained library (e.g., oauth2-pkce-node, AppAuth for iOS/Android, or oauth2-pkce-js) to generate the verifier and challenge automatically. Verify that your authorization server advertises support for S256 (often in its metadata under code_challenge_methods_supported). Finally, pair PKCE with short‑lived access tokens, secure token storage (in‑memory or encrypted storage), and, if you use refresh tokens, enforce rotation. This combination gives you strong protection against code interception while keeping the client secret‑free.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.