simkl.com, you get a code back, and you exchange it for tokens.
PKCE is mandatory, for every client type — including Server apps & services, which have a client_secret. If you have not generated a verifier and challenge before, start at PKCE in AUTH V2 and come back.
The flow
Four participants, and the two Simkl hosts do different jobs: Note that the verifier never travels through the browser — only its hash does. That is the whole point of PKCE: someone who intercepts the redirect gets a code they cannot exchange.Generate a PKCE pair and a state value
Generate a
code_verifier (43 to 128 characters), derive the code_challenge from it, and generate a random state. Store all three somewhere that survives the browser round-trip — a server session, or secure storage on the device.state is your CSRF protection: you will compare it when the user comes back.Send the user to the consent page
Open this URL in the user’s browser:On mobile, use the platform’s secure web-auth session rather than an embedded WebView —
ASWebAuthenticationSession on iOS, Custom Tabs on Android. Google sign-in and email auth both refuse to render inside an embedded WebView, so users would hit a blank screen.Receive the redirect
On approval Simkl redirects to your Compare There is no You may notice a short interstitial before the redirect lands. That is deliberate — it gives the authorization code time to replicate between regions.
redirect_uri with:state to what you stored, and reject the callback if it does not match.If the user chooses Don’t allow, you get an error callback at the same redirect_uri instead:code. state and iss come back on the error path too, so validate them exactly as you would on success — then show a “sign-in cancelled” message and let the user retry. Treat any error value you don’t recognise the same way rather than special-casing only access_denied.This is a V2 improvement worth knowing if you are migrating. AUTH V1 never sent an error back — a declined consent redirected to
/ on simkl.com and your callback simply never fired, so V1 clients had to infer denial from a timeout. Under V2 denial is an explicit, immediate callback, and that timeout workaround can go.V1 also never sent iss, so mix-up protection is new in V2 and is something to add while you are in the callback handler anyway.Exchange the code for tokens
POST to The response:Read the
https://api.simkl.com/oauth2/token within 10 minutes:scope field rather than assuming you got what you asked for — see the scope trap below.Use the token
Send
Authorization: Bearer simkl_at_... on every request. It is good for 7 days; after that, refresh it rather than sending the user back through consent.Common mistakes
Each of these is hard to diagnose from the error alone. Worth reading before you start rather than after.One exception, and it is the useful one:
401 invalid_client does not burn the code. Client authentication runs before the code is touched, so a request rejected for a missing or wrong client_secret never gets that far.In practice that means a server app debugging its credentials still holds a live code. Fix the secret and retry the same code, as long as you are inside the 10-minute window. It is the one failure you can recover from without sending the user back through consent.Redirect URI matching, exactly
Simkl checks theredirect_uri of every sign-in request against the list registered on your app. If it does not match, Simkl shows an error page and does not redirect — which is deliberate, since redirecting to an unverified URI is how open redirects happen.
The default is an exact string match. Every character counts: letter case, a trailing /, and the query string. So https://your.app/auth/simkl does not match https://your.app/auth/simkl/.
There are no wildcards, apart from *.local below. Patterns like https://*.your.app/auth/simkl or https://your.app/* never match anything.
Always rejected: a URI with no scheme, a URI with any fragment (
#...), or an unsafe scheme such as javascript: or data:.
Examples
localhost and 127.0.0.1 are separate entries. Registering one does not accept the other, even though both are loopback — the host strings still have to be equal. Register whichever form your app actually uses, or register both. The same goes for [::1].Other local addresses get no relaxation at all: http://192.168.1.5:8080/auth/simkl needs an exact match, port included.Authorizing the same user twice
Each successful exchange creates a new, independent grant. Authorizing a second time does not revoke the first, so a user who connects your app twice ends up with two live access tokens and two live refresh tokens, both valid. So if your app re-authorizes, discard the credentials you were holding — or callPOST /oauth2/revoke on them — rather than assuming the old ones died.
Run the whole flow in one file
Before wiring any of this into your app, it is worth proving your registration works on its own. This script does the complete flow — PKCE, consent, callback, exchange, authenticated call — in about 60 lines, with no dependencies. Node 18 or newer:simkl-oauth-demo.mjs
http://127.0.0.1:8910/callback as a redirect URI on your app, then:
The loopback port is ignored at authorize time, but not at the token exchange. You can register
http://127.0.0.1:8910/callback and listen on a different port — RFC 8252 §7.3, so desktop apps can take whatever port is free. The scheme, host and path still have to match exactly.The exchange is stricter: it compares against the URI stored when the code was issued, so send the same string to both. The script uses one REDIRECT_URI constant for exactly that reason.See also
PKCE in AUTH V2
Generating the verifier and challenge, and the encoding mistakes to avoid.
Tokens and refresh
Lifetimes, the refresh grant, and revoking.
Scopes
media:read and media:write, and what happens when you get it wrong.POST /oauth2/token
Endpoint reference for all three grants.
Client libraries
Configure a stock OAuth library instead of hand-rolling this.
Errors
Every
/oauth2/* error, including why invalid_grant is vague.