Delegated access to Zev products
PKCE and state
Generating the PKCE verifier and S256 challenge, why plain is refused, and handling state so a callback cannot be forged.
Every delegated-access flow needs both PKCE and state. ZevID refuses
an authorize request without them, and sends the user back with
invalid_request.
PKCE
PKCE ties the code to the server that started the flow. Someone who
intercepts the code on its way back to your callback cannot exchange it,
because they do not have the verifier.
It runs end to end between your server and ZevID. ZevAuth passes your challenge through and adds none of its own. So the verifier you store is the one you send at the token exchange.
- Make a verifier. 32 random bytes, base64url-encoded, gives 43
characters. The allowed range is 43 to 128 characters of
A–Z a–z 0–9 - . _ ~. - Derive the challenge.
base64url(sha256(verifier)), with no padding. - Send the challenge on authorize with
code_challenge_method=S256. - Send the verifier on the token exchange as
code_verifier.
import { randomBytes, createHash } from 'node:crypto';
export function pkcePair() {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
In a browser (Web Crypto):
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
function base64url(b: Uint8Array) {
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
Even if you generate the pair in the browser, the exchange must happen on your server: it needs your secret key.
Why plain is refused
With plain, the challenge is the verifier. Anyone who sees the
authorize URL, in browser history, a proxy log or a Referer header, has
everything needed to redeem the code. S256 sends only a hash, so the URL
gives nothing away.
state
state stops a forged callback. Without it, an attacker could make your
callback exchange a code from their consent, and link their account to
your user’s session.
- Generate it fresh for every attempt: at least 128 bits, base64url.
- Store it server-side with the verifier, bound to the user’s session.
- On the callback, require that
statematches, then delete it so it cannot be used again. - Keep it to 512 characters or fewer. It is returned exactly as you sent it.
- It may carry a reference to where to send the user afterwards, such as a key into your own storage, but never the destination URL itself.
Errors you can get
error on your callback | Cause |
|---|---|
invalid_request with “state” in the description | state missing or over 512 characters. |
invalid_request mentioning PKCE | Missing challenge, a method other than S256, or a challenge that is not 43 base64url characters. |
invalid_grant at the token exchange | The verifier does not match the challenge, or the code was already used, expired, or sent with a different redirect_uri. |
Updated at, Wednesday, October 21, 2026