Quota
/ docs
Dashboard

PKCE

Proof Key for Code Exchange (RFC 7636). The authorization request commits to a random secret; the token request proves possession. Defeats authorization-code interception even when the client cannot store a long-lived secret.

Using a Quota SDK? You can skip this page.
If you're using @usequota/core or @usequota/nextjs, PKCE is handled for you. This page is for direct OAuth integrators implementing the flow in raw HTTP.
PKCE is required
Every OAuth client must send code_challenge on /oauth/authorize and code_verifier on /oauth/token. Requests without PKCE are rejected with invalid_request (authorize) or invalid_grant (token). This applies to confidential and public clients alike.

01 The flow

PKCE adds two parameters and two short steps to the standard authorization-code flow:

  1. Generate a random code_verifier (43-128 chars from [A-Za-z0-9-._~]) and store it locally.
  2. Hash it with SHA-256 and base64url-encode (no padding). That's your code_challenge.
  3. Include code_challenge and code_challenge_method=S256 on /oauth/authorize.
  4. Send the original code_verifier on /oauth/token. Quota re-hashes it and compares.

02 Authorize with challenge

GEThttps://api.usequota.ai/oauth/authorize
https://api.usequota.ai/oauth/authorize
  ?response_type=code
  &client_id=quota_client_my_app
  &redirect_uri=https://myapp.com/callback
  &state=xyz
  &scope=openid+credits.spend
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

PKCE parameters

code_challengestringThe transformed challenge. 43-128 chars from [A-Za-z0-9-._~]. For S256: BASE64URL(SHA256(code_verifier)) with no padding. For plain: the verifier itself.
code_challenge_methodstringS256 or plain. Defaults to S256 when omitted — set it explicitly anyway so the hashing choice is visible at the call site.
plain is for legacy clients only
S256 is required for security to hold against intercepted authorization codes. Use plain only if your runtime genuinely cannot compute SHA-256.

03 Exchange with verifier

POSThttps://api.usequota.ai/oauth/token
curl -X POST https://api.usequota.ai/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "redirect_uri=https://myapp.com/callback" \
  -d "client_id=quota_client_my_app" \
  -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_verifierstringrequiredThe original random string you generated and hashed into code_challenge. Must be 43-128 chars from [A-Za-z0-9-._~]. Required when the authorization code was issued with a code_challenge.
Public clients can omit client_secret
If your client is registered with token_endpoint_auth_methods=none, drop client_secret from the form body and authenticate by code_verifier alone. This is the canonical mobile and SPA setup.

04 Errors

invalid_requestcode_challenge is not 43-128 chars from the allowed set, or code_challenge_method is something other than S256 or plain, or method was set without a challenge.
invalid_grantcode_verifier missing on a PKCE-bound code, malformed, or its S256 hash does not match the stored code_challenge.

05 Manual flow (Browser / Web Crypto)

Server-side Node integrators: use openid-client or another OAuth library — it does PKCE for you and gets the edge cases right. The example below is for browsers, where PKCE is most often hand-rolled.

Same idea on the client side. The Web Crypto API is available in every modern browser; no dependencies needed.

function base64url(buf: ArrayBuffer): string {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const code_verifier = base64url(verifierBytes.buffer);

const hash = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(code_verifier),
);
const code_challenge = base64url(hash);

sessionStorage.setItem("pkce_verifier", code_verifier);

location.href =
  "https://api.usequota.ai/oauth/authorize?" +
  new URLSearchParams({
    response_type: "code",
    client_id: "quota_client_my_app",
    redirect_uri: location.origin + "/callback",
    state: crypto.randomUUID(),
    scope: "openid profile email",
    code_challenge,
    code_challenge_method: "S256",
  });
Bind the verifier to the request
The code_verifier must survive the redirect. Store it in sessionStorage (same-tab) or in a server-side store keyed by state. Never put it in the URL or local storage shared across tabs — that defeats the purpose.