---
title: PKCE and state
description: 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.

1. **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 - . _ ~`.
2. **Derive the challenge.** `base64url(sha256(verifier))`, with no padding.
3. **Send the challenge** on authorize with `code_challenge_method=S256`.
4. **Send the verifier** on the token exchange as `code_verifier`.

```ts
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):

```ts
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 `state` matches, 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.

> **Warning:**
> Store the verifier and `state` **server-side**, not in a cookie you would
> trust as-is or in local storage. A verifier that leaves your server can
> redeem the code it protects.

## 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`. |
