Skip to main content
PKCE (Proof Key for Code Exchange, pronounced “pixy”) is required for any app that can’t safely store a client secret — SPAs, mobile apps, desktop apps, and browser extensions.

Why PKCE matters

Without PKCE, a leaked authorization code can be exchanged for tokens by anyone who intercepts it. PKCE binds the code exchange to the specific browser session that initiated the login:
  1. Your app generates a random code_verifier string
  2. It hashes it to produce a code_challenge and sends that to Ave
  3. When exchanging the code for tokens, it sends the original code_verifier
  4. Ave hashes the verifier and checks it matches the stored challenge
An attacker who intercepts the code can’t exchange it without the original code_verifier, which never left your app.

Implementation checklist

Generate a high-entropy code_verifier (at least 43 characters, URL-safe)
Hash it with SHA-256 and base64url-encode to get code_challenge
Send code_challenge and code_challenge_method=S256 in the authorization request
Store code_verifier, state, and nonce in sessionStorage (not localStorage)
Verify the returned state on the callback before exchanging the code — prevents CSRF attacks
Clear stored values immediately after a successful exchange — they are single-use

Implementation with @ave-id/sdk

1

Redirect the user

import { startPkceLogin } from "@ave-id/sdk/client";

await startPkceLogin({
  clientId: "YOUR_CLIENT_ID",
  redirectUri: "https://yourapp.com/callback",
  scope: "openid profile email offline_access",
});
// Automatically generates PKCE verifier/challenge, nonce, and state,
// stores them in sessionStorage, and redirects to aveid.net/signin
2

Handle the callback

import { finishPkceLogin } from "@ave-id/sdk/client";

// On your /callback page:
const tokens = await finishPkceLogin({
  clientId: "YOUR_CLIENT_ID",
  redirectUri: "https://yourapp.com/callback",
});

if (tokens) {
  // tokens.id_token, tokens.access_token, tokens.refresh_token, etc.
}
finishPkceLogin reads the stored PKCE state, verifies the returned state against the stored value (CSRF check), exchanges the code, verifies the returned tokens, and clears sessionStorage. It returns null when no code param is present — safe to call on every page load.

Common PKCE failures

The code_verifier stored before the redirect doesn’t match what you send at exchange time.Most common causes:
  • User opened a second browser tab, overwriting the stored verifier
  • sessionStorage was cleared between redirect and callback (e.g., redirect to a new window)
  • Base64url encoding bug — the verifier must use URL-safe characters (- and _, not + and /)
Fix: Use the SDK helpers which handle encoding correctly. If rolling your own, ensure btoa() output is made URL-safe.
Token exchange returns invalid_request even though you’re sending the code.Cause: Forgetting to include codeVerifier in the token exchange request when the authorization request included a code_challenge.Fix: Always include codeVerifier when using PKCE — even when using a confidential client that also sends a clientSecret.
Token exchange fires twice, and the second call fails with invalid_grant (code already used).Cause: React strict mode, HMR, or a route that mounts twice.Fix: Use a ref or state flag to ensure exchangeCode is called exactly once per page load. In React:
const exchanged = useRef(false);
useEffect(() => {
  if (exchanged.current) return;
  exchanged.current = true;
  handleCallback();
}, []);

Mobile-specific concerns

On iOS and Android, the app may be backgrounded or terminated between the authorization redirect and the callback. Use app-lifecycle-safe storage (e.g. encrypted shared preferences on Android, Keychain on iOS) rather than in-memory session storage.
Some Android launchers can deliver the same deep link intent multiple times. Guard against this with the same deduplication pattern as the React strict mode fix above.
Last modified on March 8, 2026