client_secret. The browser-based UX is identical to standard OAuth; the only difference is that the client_secret is replaced by a one-time code_verifier + code_challenge pair the client generates locally.
Even if an attacker intercepts the redirect with the authorization code, they can’t redeem it without the verifier — and the verifier never leaves your app until the final POST.
Pick this flow if…
If you’d otherwise be embedding
client_secret in a public binary, you want PKCE.
At a glance
Three steps, two HTTP calls:
Tokens last until the user revokes from Connected Apps — there’s no refresh-token flow.
Detailed flow
Generate a verifier and challenge
Locally, before any redirect:
code_verifier requirements (RFC 7636 §4.1):- 43–128 characters
- Only the unreserved URI characters:
A-Z,a-z,0-9, and- . _ ~ - Cryptographically random — never reuse across flows
401 secret_error (“PKCE verification failed”). The error message intentionally matches what a wrong-but-well-formed verifier returns, so format probing reveals nothing.code_challenge_method — Simkl supports two values:Where to keep the verifier between step 1 and step 3:
Authorize
Open a browser session to:Use a platform-secure browser session — not an embedded WebView:
- iOS / iPadOS —
ASWebAuthenticationSession - Android — Chrome Custom Tabs (or the newer Auth Tab on supported Chrome versions)
- Desktop — the user’s system browser, with a localhost loopback redirect
- SPA — a normal full-page navigation
- Browser extension —
chrome.identity.launchWebAuthFlow/browser.identity.launchWebAuthFlow
redirect_uri with ?code=AUTHORIZATION_CODE&state=YOUR_RANDOM_CSRF_TOKEN appended.Exchange the code for a token
Once you receive the authorization Notice: no Where to store it:
code, POST to the token endpoint with the verifier instead of client_secret:POST https://api.simkl.com/oauth/token
Both content-types and both credential locations work. Simkl’s For library-specific examples (Python, Node, Java, Go, PHP), see OAuth client libraries — most are zero-config.
POST /oauth/token accepts:Content-Type: application/x-www-form-urlencoded(the RFC 6749 §3.2 default) orContent-Type: application/json— pick whichever your HTTP client prefers- Client credentials in the request body (
client_id+client_secretparameters) or in theAuthorization: Basicheader (RFC 6749 §2.3.1) — both paths are honored
client_secret. Simkl re-derives the challenge from the verifier you send and matches it against what you sent in step 2. If they match, you get back an access_token. If they don’t, the request fails — that’s the protection: an attacker who intercepted the redirect doesn’t have the verifier.Successful response (200):expires_in is 5 years in seconds — Simkl tokens are long-lived and there’s no refresh-token grant. If a 401 ever arrives before that lifetime elapses, the user revoked your app at Connected Apps settings; start a fresh PKCE flow.PKCE failure (401):secret_error is also returned for a wrong client_secret on the confidential flow — distinguishable by the message field. Don’t retry the same code after this error — any exchange attempt (success OR failure) consumes the code (RFC 6749 §4.1.2). Restart the flow from /oauth/authorize with a fresh verifier+challenge pair.Treat the access_token as long-lived — store it securely and reuse it on every authenticated request:Choose a redirect URI
PKCE works with any redirect URI scheme — but each option has trade-offs:
Whichever you pick, the URI you send in step 2 must match a URL registered in your app settings byte-for-byte (scheme, host, port, path, trailing slash, casing).
If you can’t host a redirect target at all — TVs, consoles, CLI tools — use the PIN flow instead. That’s the browser-less alternative; the user enters a 5-character code at
simkl.com/pin and your app polls for the result.
Platform recipes
- iOS / Swift
- Android / Kotlin
- Web (SPA) / JavaScript
- Desktop / Python
Full PKCE flow with
ASWebAuthenticationSession and CryptoKit:Common pitfalls
The token exchange returns an error
The token exchange returns an error
Most common causes:
code_verifierdoesn’t match the originalcode_challenge— you probably regenerated the verifier between step 2 and step 3, or stored/loaded it incorrectly (URL-encoded once but not the other, padded vs unpadded base64). Returns401 secret_errorwith"PKCE verification failed"in the message field.- Authorization code expired or already used — codes are short-lived AND single-use (RFC 6749 §4.1.2). Any exchange attempt — success OR failure — consumes the code. If a previous attempt failed for any reason (wrong verifier, format error, network glitch after the request reached the server), the code is gone. Restart from
/oauth/authorizewith a fresh verifier+challenge. redirect_urinot registered — the URI you send in step 3 must be a registered URL for your app (same whitelist as step 2). Simkl doesn’t currently enforce that step 2 and step 3 use the exact same string, but stricter OAuth implementations do — send the same value both times for forward-compat.code_challenge_methodwas wrong-cased on authorize — the authorize endpoint rejects anything outside{S256, plain}(case-sensitive) with400 invalid_request. Re-run the flow withS256.
`base64url` vs standard `base64` mismatch
`base64url` vs standard `base64` mismatch
PKCE requires base64url with no padding (RFC 4648 §5):
- Use
-instead of+ - Use
_instead of/ - Strip trailing
=characters
+, /, and =) and you have to opt into the URL-safe variant. If your verifier or challenge contains any of those three characters, the server-side comparison will silently fail and you’ll get PKCE verification failed. The base64url helper at the top of every recipe above does the substitution explicitly.The user lands on simkl.com instead of my app
The user lands on simkl.com instead of my app
Your
redirect_uri parameter doesn’t match a URL registered for your app in developer settings, or the redirect didn’t make it back to your app (custom-scheme handler not registered, Universal Link not signed, etc.). Register the URI byte-for-byte, then pass the same string in step 2. If your app genuinely has no place to redirect (TV, console, CLI), use the PIN flow instead.iOS in-app browser doesn't return the redirect
iOS in-app browser doesn't return the redirect
Use
ASWebAuthenticationSession (not SFSafariViewController or a WKWebView) — only ASWebAuthenticationSession lets your app receive the deep-link callback when the user returns. Set callbackURLScheme to your registered scheme.Multiple verifier/challenge pairs in flight
Multiple verifier/challenge pairs in flight
Each authorization flow should use its own pair. If the user has two browser tabs trying to authorize in parallel, store the verifier under a per-flow key (e.g.
pkce_verifier_<state> where state is also passed to /authorize and echoed back).`state` mismatch after the redirect
`state` mismatch after the redirect
If your verification rejects a redirect because
state doesn’t match what you stored, treat it as a CSRF attempt — discard the code and don’t proceed to the exchange. Most often this is a real bug (you forgot to persist state across the redirect, or you’re reading it from the wrong storage), but it can also indicate that another app on the device intercepted your scheme. Restart the flow with a fresh verifier+state pair.See also
OAuth 2.0 endpoints
GET /oauth/authorize and POST /oauth/token — both used in this PKCE flow, with parameter combinations specific to public clients.Choose a flow
Big-picture comparison: OAuth 2.0 (confidential) vs OAuth 2.0 + PKCE (public) vs PIN (browser-less).