Recipe: PKCE and Authorize URLs for Fitness APIs
Last verified November 5, 2026 · 8 min read
Covered here:OuraWHOOPStravaPolar
The authorize redirect looks like string concatenation and fails like a protocol: base64 where base64url was required, a comma where the vendor wanted a space, a state the vendor rejects for its length, a PKCE parameter sent to a server that never asked for it. This recipe builds the redirect and checks the callback for six fitness APIs, with PKCE implemented to RFC 7636 and tested against the RFC's own example.
What RFC 7636 requires#
A code verifier is 43*128unreserved, where unreserved is ALPHA / DIGIT / "-" / "." / "_" / "~". The RFC recommends that 32 random octets be "base64url-encoded to produce a 43-octet URL safe string". The S256 challenge is BASE64URL-ENCODE(SHA256(ASCII(code_verifier))). "If the client is capable of using "S256", it MUST use "S256"", code_challenge_method "defaults to "plain" if not present in the request", and a verifier that does not match is answered with invalid_grant. The RFC was read on November 5, 2026, through the site's source-snapshot workflow (evidence in ops/sources/evidence/cookbook-pkce-authorize-url.json).
The six authorize endpoints#
| Vendor | Authorize URL | Scopes | PKCE | Notes |
|---|---|---|---|---|
| Oura | https://cloud.ouraring.com/oauth/authorize | space-separated | recommended, S256 | |
| WHOOP | https://api.prod.whoop.com/oauth/oauth2/auth | space-separated | not documented | add offline for a refresh token; a state you generate must be 8 characters |
| Wahoo | https://api.wahooligan.com/oauth/authorize | space-separated | for non-confidential apps | |
| Strava | https://www.strava.com/oauth/authorize | comma-separated | not documented | approval_prompt is auto or force |
| Polar | https://flow.polar.com/oauth2/authorization | accesslink.read_all | not documented | "code" is the only response type |
| Withings | https://account.withings.com/oauth2_user/authorize2 | comma-separated | not documented | the code is valid for 30 seconds |
Each row is from the site's integration guide for that vendor (Oura, WHOOP, Wahoo, Strava, Polar, Withings).
What the recipe does#
- Generates the verifier from 32 random octets and refuses, in
challengeS256, any verifier outside the RFC's grammar. - Has no plain mode. There is nothing to downgrade to.
- Builds the URL per vendor: the right endpoint, separator and extras, spaces encoded as
%20, PKCE only where documented. - Generates state by rejection sampling over letters and digits, at WHOOP's eight characters or 32 elsewhere.
- Checks the callback: state first and in constant time, then
error(Oura and Strava documentaccess_denied), thencode, returning the grantedscopewhen the vendor sends it.
The implementation#
The canonical file is cookbook/pkce-authorize-url.mjs in this site's repository, and cookbook/pkce-authorize-url.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and needs Node 20 or newer; randomness is injectable, which is how the tests feed it the RFC's octets.
/**
* pkce-authorize-url.mjs — the first leg of an OAuth authorization-code flow
* for fitness APIs: a PKCE verifier and S256 challenge, a state value, the
* vendor's authorize URL with its own scope separator, and the callback
* check that turns `?code=…&state=…` into a code or a reason.
*
* WHAT THIS IMPLEMENTS
* PKCE, per RFC 7636:
* - a verifier is 43 to 128 characters from A-Z a-z 0-9 - . _ ~; the RFC
* recommends 32 random octets, base64url-encoded, which is 43 characters
* - S256: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
* - a client that can use S256 MUST use it, and must not fall back to
* "plain", so this file has no plain mode at all
* Vendors, as quoted on the site's integration guides:
* oura cloud.ouraring.com/oauth/authorize; space-separated scopes;
* PKCE recommended with S256
* whoop api.prod.whoop.com/oauth/oauth2/auth; add `offline` to get a
* refresh token; a state you generate must be 8 characters
* wahoo api.wahooligan.com/oauth/authorize; PKCE for apps that are
* not confidential (Wahoo accepts plain or S256: S256 here)
* strava www.strava.com/oauth/authorize; comma-separated scopes;
* approval_prompt auto (default) or force
* polar flow.polar.com/oauth2/authorization; response type "code"
* only; one scope, accesslink.read_all
* withings account.withings.com/oauth2_user/authorize2; comma-separated
* scopes. Its token exchange is a signed requesttoken call,
* which this file does not cover.
* PKCE is sent only to vendors whose guide documents it, so a vendor that
* ignores unknown parameters is never relied on to ignore them.
*
* `readCallback(query, expected)` compares state in constant time and maps
* `error=access_denied` (documented by Oura and Strava) to a refusal rather
* than an exception: a user saying no is not a bug.
*
* PAGES THIS SUPPORTS
* https://aifitnessapi.com/integrate/oura-api (and whoop-api, wahoo-api,
* strava-api, polar-api, withings-api)
* https://aifitnessapi.com/fix/oauth-redirect-uri-mismatch
*
* Randomness is injectable for tests. No dependencies. Node 20+.
*
* MIT — from aifitnessapi.com/cookbook
*/
import { createHash, randomBytes as nodeRandomBytes, timingSafeEqual } from "node:crypto";
export const VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
export const VENDORS = Object.freeze({
oura: { authorize: "https://cloud.ouraring.com/oauth/authorize", scopeSeparator: " ", pkce: true },
whoop: { authorize: "https://api.prod.whoop.com/oauth/oauth2/auth", scopeSeparator: " ", pkce: false, stateLength: 8 },
wahoo: { authorize: "https://api.wahooligan.com/oauth/authorize", scopeSeparator: " ", pkce: true },
strava: { authorize: "https://www.strava.com/oauth/authorize", scopeSeparator: ",", pkce: false, extra: { approval_prompt: "auto" } },
polar: { authorize: "https://flow.polar.com/oauth2/authorization", scopeSeparator: " ", pkce: false },
withings: { authorize: "https://account.withings.com/oauth2_user/authorize2", scopeSeparator: ",", pkce: false },
});
/** RFC 4648 base64url without padding. */
export function base64url(bytes) {
return Buffer.from(bytes).toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
/** A verifier from 32 random octets (43 characters), as RFC 7636 recommends. */
export function createVerifier({ randomBytes = nodeRandomBytes } = {}) {
return base64url(randomBytes(32));
}
/** S256 challenge for a verifier; refuses a verifier RFC 7636 would not accept. */
export function challengeS256(verifier) {
if (!VERIFIER_PATTERN.test(verifier)) throw new RangeError("code_verifier must be 43-128 characters of A-Z a-z 0-9 - . _ ~");
return base64url(createHash("sha256").update(verifier, "ascii").digest());
}
const STATE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** An unguessable state; WHOOP's is exactly 8 characters. */
export function createState(vendor, { randomBytes = nodeRandomBytes, length } = {}) {
const n = length ?? VENDORS[vendor]?.stateLength ?? 32;
// Rejection sampling: 62 does not divide 256, so bytes >= 248 are redrawn.
let out = "";
while (out.length < n) {
for (const b of randomBytes(n * 2)) {
if (b < 248 && out.length < n) out += STATE_ALPHABET[b % 62];
}
}
return out;
}
/**
* Everything the redirect needs, plus what to keep server-side until the
* callback: the state and (where used) the verifier.
*/
export function startAuthorization(vendor, { clientId, redirectUri, scopes = [], randomBytes = nodeRandomBytes, extra = {} }) {
const v = VENDORS[vendor];
if (!v) throw new Error(`no authorize rule for ${vendor}`);
if (!clientId || !redirectUri) throw new TypeError("clientId and redirectUri are required");
const state = createState(vendor, { randomBytes });
const params = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, state });
if (scopes.length) params.set("scope", scopes.join(v.scopeSeparator));
let verifier = null;
if (v.pkce) {
verifier = createVerifier({ randomBytes });
params.set("code_challenge", challengeS256(verifier));
params.set("code_challenge_method", "S256");
}
for (const [k, val] of Object.entries({ ...(v.extra ?? {}), ...extra })) params.set(k, val);
return { url: `${v.authorize}?${params.toString().replace(/\+/g, "%20")}`, state, verifier };
}
function sameString(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
/**
* @param {URLSearchParams|Record<string,string>|string} query the callback's query
* @param {{ state: string }} expected what startAuthorization returned
* @returns {{ ok: true, code: string, grantedScope: string|null } | { ok: false, reason: "denied"|"state-mismatch"|"no-code", error?: string }}
*/
export function readCallback(query, expected) {
const q = query instanceof URLSearchParams ? query : new URLSearchParams(typeof query === "string" ? query.replace(/^.*\?/, "") : query);
if (!sameString(q.get("state") ?? "", expected.state)) return { ok: false, reason: "state-mismatch" };
const error = q.get("error");
if (error) return { ok: false, reason: "denied", error };
const code = q.get("code");
if (!code) return { ok: false, reason: "no-code" };
return { ok: true, code, grantedScope: q.get("scope") };
}
How to adapt it#
Keep state and verifier server-side, keyed to the browser session that started the flow, and delete them after the callback. The verifier never goes in the redirect.
Register the exact redirect URI. Vendors compare it as a string; the redirect_uri mismatch fix lists the ways it differs.
Exchange the code immediately. Withings' code lasts 30 seconds and Polar deletes every issued token if a code is used twice. Then store the grant with refresh-token rotation and the token vault.
The contract the tests hold it to#
RFC 7636 Appendix B: the listed octets give the listed verifier and that verifier the listed challenge; Appendix A: 3 236 255 224 193 encodes to A-z_4ME. Verifiers are 43 characters by default and 42, 129 and illegal-character verifiers are refused. Oura gets S256 and %20-encoded scopes; WHOOP gets an eight-character state, offline in the scope and no PKCE; Strava gets commas and approval_prompt; Wahoo gets S256; Polar and Withings get their own endpoints and separators; only Oura and Wahoo get PKCE. The callback check returns a code, a denial, a state mismatch or a missing code, and bad arguments throw.
What it deliberately does not do#
It does not exchange the code for tokens, because the exchanges differ more than the redirects do: Polar authenticates with HTTP Basic, Withings signs a requesttoken call with a nonce, and Wahoo swaps the secret for the verifier. It does not cover the Google Health API, which uses Google's OAuth.
Frequently asked questions
How do I test a PKCE implementation against RFC 7636?
Use the worked example in Appendix B of RFC 7636. Feed the 32 octets it lists to your verifier generator and you must get dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk; hash that verifier with SHA-256 and base64url-encode it without padding and you must get E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. Appendix A adds a base64url check: the octets 3, 236, 255, 224 and 193 encode to A-z_4ME. The recipe's tests run all three, which catches the usual faults at once: standard base64 instead of base64url, padding left on, and hashing the raw octets instead of the ASCII verifier.
Should a fitness app ever use the plain PKCE code_challenge_method?
Not if it can compute SHA-256, which every current runtime can. RFC 7636 says a client capable of using S256 must use it, that plain exists only for compatibility and constrained environments, and that clients must not downgrade to plain after trying S256. Wahoo's portal accepts plain or S256, as the site's Wahoo guide reports, but accepting plain is the server's compatibility promise, not a reason to send it. Note too that leaving out code_challenge_method makes the server assume plain, so send it explicitly. The recipe has no plain mode at all.
Which fitness APIs document PKCE on their authorization endpoint?
Among the six this recipe covers, Oura and Wahoo, as the site's integration guides report them. Oura marks PKCE as recommended, with a code_verifier of 43 to 128 characters and code_challenge_method=S256; Wahoo supports it for apps whose Confidential setting is No, passing the code_verifier on the token and refresh calls instead of the client secret. The WHOOP, Strava, Polar and Withings guides on this site do not document it, so the recipe does not send it to them: relying on a server to ignore parameters it does not understand is a bet, not a design.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Corrections that change a page are logged, and credited by name if you ask: contributors.
Cite this page
- Title
- Recipe: PKCE and Authorize URLs for Fitness APIs
- Publisher
- AIFitnessAPI
- URL
- https://aifitnessapi.com/cookbook/pkce-authorize-url
- Last verified
AIFitnessAPI. "Recipe: PKCE and Authorize URLs for Fitness APIs." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/pkce-authorize-url
@misc{aifitnessapi-cookbook-pkce-authorize-url,
author = {{AIFitnessAPI}},
title = {Recipe: PKCE and Authorize URLs for Fitness APIs},
year = {2026},
url = {https://aifitnessapi.com/cookbook/pkce-authorize-url},
urldate = {2026-11-05},
note = {Last verified 2026-11-05}
}Cite the page, not the site — every page carries its own verification date.
Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All cookbook · by AIFitnessAPI