PKCE — Proof Key for Code Exchange, RFC 7636 — is the secure way to run OAuth on a public client: any app where you can’t safely embed aDocumentation Index
Fetch the complete documentation index at: https://api.simkl.org/llms.txt
Use this file to discover all available pages before exploring further.
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…
| Use PKCE | Use a different flow |
|---|---|
| iOS, iPadOS, watchOS apps | Server-side web apps that can keep a secret → OAuth 2.0 |
| Android, Wear OS apps | TVs, consoles, smart watches, CLI tools → PIN flow |
| Single-page apps (React, Vue, Svelte, vanilla JS) | |
| Browser extensions (Chrome, Firefox, Edge) | |
| Desktop binaries (Electron, Tauri, native) |
client_secret in a public binary, you want PKCE.
At a glance
Three steps, two HTTP calls:| Where | What you send | What you get | |
|---|---|---|---|
| 1. Generate locally | client | — | A random code_verifier (43–128 chars) and its SHA-256 code_challenge |
| 2. Authorize (browser) | simkl.com/oauth/authorize | client_id, code_challenge, code_challenge_method=S256, state | After consent: a ?code=…&state=… redirect |
| 3. Exchange (HTTP POST) | api.simkl.com/oauth/token | code, client_id, code_verifier | A long-lived access_token |
Detailed flow
Generate a verifier and challenge
Locally, before any redirect:
Where to keep the verifier between step 1 and step 3:
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:| Method | How it works |
|---|---|
S256 (default, recommended) | code_challenge = base64url(sha256(code_verifier)) — protects the verifier even if the authorize URL leaks |
plain | code_challenge = code_verifier — only acceptable on platforms without SHA-256 (essentially never in 2026) |
| Platform | Storage |
|---|---|
| iOS / Android | In-process memory (or Keychain / EncryptedSharedPreferences if the flow may be backgrounded) |
| SPA | sessionStorage — survives the redirect, cleared on tab close |
| Desktop CLI / loopback | Process memory while the local HTTP server waits |
| Browser extension | chrome.storage.session (or the equivalent) |
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:| Platform | Storage |
|---|---|
| iOS / iPadOS | Keychain |
| Android | EncryptedSharedPreferences or the Android Keystore |
| macOS / Windows / Linux | OS keychain (Keychain Access, Credential Manager, libsecret) |
| SPA | httpOnly cookie set by your backend — never localStorage |
| Browser extension | chrome.storage.local (or the equivalent) |
Choose a redirect URI
PKCE works with any redirect URI scheme — but each option has trade-offs:| Type | Example | Best for | Notes |
|---|---|---|---|
| Custom URI scheme | myapp://oauth | Mobile (iOS, Android) | Simplest. Risk: another app could in principle register the same scheme — PKCE mitigates this since the attacker can’t redeem the code without your verifier. |
| Universal Link / App Link | https://yourdomain.com/oauth | Mobile (iOS 9+, Android 6+) | Cryptographically bound to your domain via apple-app-site-association / assetlinks.json. Strongest mobile option. |
| HTTPS callback | https://yourdomain.com/callback | SPA | Standard web. The page reads ?code= from the URL and POSTs to the token endpoint. |
| Loopback | http://127.0.0.1:8765/callback | Desktop / CLI | Spin up a local HTTP server on a free port; Simkl redirects there. Use 127.0.0.1, not localhost. |
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.| Parameter | Required | Value |
|---|---|---|
client_id | always | Your client_id from your developer settings. |
app-name | always | Short, lowercase identifier for your app (e.g. plex-scrobbler, kodi-trakt-bridge). |
app-version | always | The current version of your app, e.g. 1.0, 2.4.1. |
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).