Skip to main content
This is the flow for anything that can open a browser. The user approves your app on 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.
Two domains, two roles. OAuth V2 uses two different hosts — easy to mix up, and the most common cause of “404 Not Found” during integration:Only the consent page lives on simkl.com. If your authorize URL points at api.simkl.com you’ll get a 404./oauth2/authorize is the odd one out. The other three are requests your code makes and reads a response from. This one is a destination you hand to a browser — open it with the platform’s web-auth session, or redirect to it. Fetching it yourself gets you a login redirect, not a code.Setting these four URLs directly is fine and is what the examples here do. If your library prefers it, all four are also listed in Simkl’s discovery document, which resolves them from the single issuer setting https://simkl.com.

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.
redirect_uri must match your registration character for character. Comparison is a plain string match, so every one of these is rejected with 400 invalid_request — “redirect_uri must match a registered URI”:The error names the parameter but not what differs, so a stray slash looks identical to a wrong host. If authorization fails immediately and you haven’t reached a consent screen, compare the two strings byte for byte before looking anywhere else.Put your per-request values in state, not in the redirect URL.

Receive the redirect

On approval Simkl redirects to your redirect_uri with:
Compare 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:
There is no 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.
Check iss too, not just state. URL-decode it and compare the result to https://simkl.com — Simkl’s issuer identifier, and the issuer value in the discovery document. Use a plain equality check, not a prefix or substring test. If it does not match, reject the callback and do not exchange the code (RFC 9207 §2.4).
This defends against mix-up attacks, and it matters more than it first looks. The threat applies to any client that talks to more than one authorization server — which describes most media apps, since they commonly offer Simkl alongside Trakt or another tracker. If one of those servers is hostile or compromised, it can trick your client into sending a Simkl authorization code to it instead. iss is what lets you tell the responses apart.An app that talks only to Simkl is not vulnerable today — but it becomes vulnerable the moment someone adds a second provider, which is exactly the change nobody revisits their callback handler for. Checking it now costs one line.state does not cover this: an attacker running the other authorization server sees the state you sent it, so it survives the check. Note also that iss is not cryptographically signed — it defends against mix-up, not against an attacker who can already read your redirect.
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.
You may notice a short interstitial before the redirect lands. That is deliberate — it gives the authorization code time to replicate between regions.

Exchange the code for tokens

POST to https://api.simkl.com/oauth2/token within 10 minutes:
The response:
Read the 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.
The authorization code is destroyed even when the exchange fails. Once the request passes client authentication, the code is consumed before the grant is validated. A wrong code_verifier, a mismatched redirect_uri, or a client_id that did not issue the code all burn it on the way to the error.So you cannot fix the request and retry with the same code. Every failed attempt costs a full trip back through the consent screen. This catches people who write a retry loop around the exchange, because the retry looks like it should work and never will.
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.
But invalid_client masks every other error. Because client authentication is checked first, a missing secret returns 401 invalid_client no matter what else is wrong with the request. You can have a malformed code_verifier and a bad redirect_uri at the same time and see neither until the secret is right.Fix invalid_client first, then debug the rest — and expect the next attempt to reveal a problem you thought you had already ruled out.
Omitting scope gives you a read-only token, and a typo does too. If you leave scope off the authorize URL you get media:read — not both scopes. Worse, an unrecognised scope string is silently treated as read-only rather than rejected, so media:wrote yields a working token that cannot write, with no error anywhere.The failure surfaces much later as a 403 on your first write. Check the scope in the token response and treat a mismatch as a configuration bug.

Redirect URI matching, exactly

Simkl checks the redirect_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.
Send the same redirect_uri on the token exchange that you sent to authorize — including the port. The exchange compares your value against the one stored when the code was issued, character for character, and the loopback relaxation does not apply a second time.This bites loopback clients specifically. If you registered http://127.0.0.1/auth/simkl and started sign-in on http://127.0.0.1:53682/auth/simkl, the exchange must send http://127.0.0.1:53682/auth/simkl — the port you actually used, not the registered form. Sending the registered form is invalid_grant, and it burns the code.
A registered https://app/cb does not match a requested https://app/cb?x=1. The query string is part of the exact match, so appending your own parameter breaks it. Put per-request data in state instead — it is the supported way to carry data through the flow and works identically for every client type.
Redirect URI validation runs before the PKCE check. While your redirect URI is wrong, you will not be told that your PKCE parameters are also missing or malformed. Get the redirect URI right first, or you will fix one bug and immediately meet another you thought you had already ruled out.

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 call POST /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
Register 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.