Guides
PKCE in a browser app
Prove the code exchange without a secret, from a browser or native app.
A public application has no secret, so PKCE (Proof Key for Code Exchange) is how WOOL knows that the app exchanging a code is the app that started the flow. You generate a random value, the verifier, keep it in the app, and send its SHA-256 hash, the challenge, on the auth link. At exchange time you send the verifier, and WOOL checks that it hashes to the challenge. PKCE is optional in WOOL and recommended for every browser or native app.
Generate the pair
The helper below generates a verifier and challenge for PKCE, plus a random state value. Your app checks
state when the user returns to match the callback to the authorization it started.
// PKCE for a public client: a random verifier and its S256 challenge, plus a state value to check on return.
// Uses only Web APIs, so it runs in a browser and in Node.
const base64url = bytes =>
btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/u, '');
export function createPkce() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
return crypto.subtle
.digest('SHA-256', new TextEncoder().encode(verifier))
.then(digest => ({ challenge: base64url(new Uint8Array(digest)), state, verifier }));
}
export function authLink({ applicationId, challenge, redirectUri, state, website }) {
const url = new URL('https://connect.trywool.com/v1/oauth/authorize');
url.search = new URLSearchParams({
client_id: applicationId,
code_challenge: challenge,
code_challenge_method: 'S256',
redirect_uri: redirectUri,
response_type: 'code',
state,
website,
}).toString();
return url.toString();
}
createPkce() returns the verifier, its challenge, and a state value. It uses only Web APIs, so the same file
runs in a browser and in Node. Keep verifier and state in session storage keyed by state, and reject any
callback whose state you don't recognise.
Build the link
The authLink() helper in pkce.mjs takes the challenge and state from createPkce(), along with your
application id, registered redirect URI, and the website to connect. It adds code_challenge and
code_challenge_method=S256 to the auth link. Once a link carries a challenge, the exchange must present the
matching verifier, so a stolen code is useless to anyone else.
If you leave PKCE out
Without a challenge, anyone who can see the redirect, such as another app on the same custom scheme, a leaked referrer, or a shared browser log, can exchange the code themselves and receive a long-lived token to the user's account. If you skip PKCE, keep the redirect on a URL only your app can receive, exchange the code the moment it arrives, and prefer the confidential flow on a backend where you can.
Handle the callback
You can handle the callback on a backend even when using a public client. This Node example imports pkce.mjs,
sends the user to the auth link from /start, and exchanges the code when they return. Set APPLICATION_ID and
REDIRECT_URI before running it; the Quickstart walks through local setup.
// A minimal server for a public client: /start sends the user to the auth link, /callback exchanges the code.
import { createServer } from 'node:http';
import { authLink, createPkce } from './pkce.mjs';
const API = process.env.WOOL_API_ORIGIN ?? 'https://api.trywool.com';
const { APPLICATION_ID, REDIRECT_URI, WEBSITE = 'mail.google.com' } = process.env;
const callbackPath = new URL(REDIRECT_URI).pathname;
// Verifiers waiting for their callback, keyed by state. Use your session store in a real app.
const pending = new Map();
const reply = (response, status, body) => {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
};
const server = createServer(async (request, response) => {
const url = new URL(request.url, REDIRECT_URI);
if (url.pathname === '/start') {
const { challenge, state, verifier } = await createPkce();
pending.set(state, verifier);
const location = authLink({
applicationId: APPLICATION_ID,
challenge,
redirectUri: REDIRECT_URI,
state,
website: WEBSITE,
});
response.writeHead(302, { Location: location });
return response.end();
}
if (url.pathname === callbackPath) {
const state = url.searchParams.get('state');
const verifier = pending.get(state);
pending.delete(state);
if (!verifier) return reply(response, 400, { error: 'unknown state' });
if (url.searchParams.get('error')) return reply(response, 200, { declined: url.searchParams.get('error') });
const token = await fetch(`${API}/v1/oauth/token`, {
body: new URLSearchParams({
client_id: APPLICATION_ID,
code: url.searchParams.get('code'),
code_verifier: verifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
}),
method: 'POST',
}).then(result => result.json());
// Store token.access_token with token.connection_id and token.user_id against your user here.
return reply(response, 200, { connection_id: token.connection_id, user_id: token.user_id });
}
reply(response, 404, { error: 'not found' });
});
server.listen(Number(process.env.PORT ?? 3001), () => console.log(`listening on ${server.address().port}`));
When the user returns:
- Read
codeandstatefrom the query. - If
stateis unknown, stop. Someone else started that flow. - If
erroris present, the user declined (access_denied) or the sign-in failed (server_error). Show a message and let them try again later. - Otherwise post to
POST /v1/oauth/tokenwithclient_id,code,code_verifier,grant_type=authorization_code, and the sameredirect_uri.
The token endpoint allows cross-origin requests, so a browser app can exchange the code directly.
Where the token should live
The token is long-lived, so in a browser-only app anyone who can read the browser's storage can run executions as that user. The default choice is to exchange the code on a backend you control and keep the token there, even for a public client. Keep the browser-only path for prototypes and apps with no backend.
Next steps
Confidential flow on a server is the backend version of this flow.