> ## Documentation Index
> Fetch the complete documentation index at: https://api.simkl.org/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth 2.0 flow (AUTH V2)

> The V2 browser flow, end to end, with PKCE. For web apps, mobile apps, SPAs, desktop binaries and extensions.

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](/api-reference/auth#client-types) — 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](/api-reference/oauth2-pkce) and come back.

<Warning>
  **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:

  | Endpoint                                               | Host                | What it does                                                                                                                          |
  | ------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
  | `/oauth2/authorize` — **browser URL, not an API call** | **`simkl.com`**     | A page you send the user to. They sign in and approve your app there; your code never requests this itself.                           |
  | `POST /oauth2/token`                                   | **`api.simkl.com`** | Server-to-server exchange. Your app posts the `code` (or a `refresh_token`, or a `device_code`) here and gets back an `access_token`. |
  | `POST /oauth2/device`                                  | **`api.simkl.com`** | Starts the device flow and returns the code the user types in.                                                                        |
  | `POST /oauth2/revoke`                                  | **`api.simkl.com`** | Invalidates a token you no longer need.                                                                                               |

  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](/api-reference/auth-v2#discovery), which resolves them from the single issuer setting `https://simkl.com`.
</Warning>

## The flow

Four participants, and the two Simkl hosts do different jobs:

```mermaid theme={"theme":{"light":"github-light","dark":"vesper"}}
sequenceDiagram
    participant C as Your app
    participant B as User's browser
    participant S as simkl.com
    participant A as api.simkl.com
    Note over C: Generate verifier, challenge and state
    C->>B: Open the authorize URL
    B->>S: GET /oauth2/authorize (challenge + state)
    Note over B,S: User signs in and approves
    S-->>B: Redirect to your callback (code + state + iss)
    B->>C: Callback arrives
    Note over C: Verify state matches
    C->>A: POST /oauth2/token (code + verifier)
    A-->>C: access_token + refresh_token
```

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.

<Steps>
  <Step title="Generate a PKCE pair and a state value" icon="dice">
    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.
  </Step>

  <Step title="Send the user to the consent page" icon="up-right-from-square">
    Open this URL in the user's browser:

    ```
    https://simkl.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=media:read%20media:write&state=RANDOM_CSRF_TOKEN&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256
    ```

    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.

    <Warning>
      **`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":

      | You send                  | Registered            | Result                     |
      | ------------------------- | --------------------- | -------------------------- |
      | `https://your.app/cb/`    | `https://your.app/cb` | rejected — trailing slash  |
      | `https://YOUR.APP/cb`     | `https://your.app/cb` | rejected — host case       |
      | `https://your.app/CB`     | `https://your.app/cb` | rejected — path case       |
      | `https://your.app/cb?x=1` | `https://your.app/cb` | rejected — extra parameter |
      | `http://your.app/cb`      | `https://your.app/cb` | rejected — scheme          |

      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.
    </Warning>
  </Step>

  <Step title="Receive the redirect" icon="arrow-left">
    <a id="receive-the-redirect" />

    On approval Simkl redirects to your `redirect_uri` with:

    ```
    ?code=AUTHORIZATION_CODE&state=YOUR_STATE&iss=https%3A%2F%2Fsimkl.com
    ```

    **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:

    ```
    ?error=access_denied&state=YOUR_STATE&iss=https%3A%2F%2Fsimkl.com
    ```

    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`.

    <Warning>
      **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](/api-reference/auth-v2#discovery). 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](https://www.rfc-editor.org/rfc/rfc9207#section-2.4)).

      ```js theme={"theme":{"light":"github-light","dark":"vesper"}}
      const params = new URL(callbackUrl).searchParams;
      if (params.get("iss") !== "https://simkl.com") {
        throw new Error("Authorization response did not come from Simkl");
      }
      ```

      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.
    </Warning>

    <Note>
      **This is a V2 improvement worth knowing if you are [migrating](/guides/migrating-v1-to-v2).** [AUTH V1](/api-reference/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.
    </Note>

    You may notice a short interstitial before the redirect lands. That is deliberate — it gives the authorization code time to replicate between regions.
  </Step>

  <Step title="Exchange the code for tokens" icon="key">
    POST to `https://api.simkl.com/oauth2/token` within 10 minutes:

    <CodeGroup>
      ```bash App or device registration (no secret) theme={"theme":{"light":"github-light","dark":"vesper"}}
      curl -X POST https://api.simkl.com/oauth2/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -H "User-Agent: my-app-name/1.0" \
        --data-urlencode "grant_type=authorization_code" \
        --data-urlencode "client_id=YOUR_CLIENT_ID" \
        --data-urlencode "code=AUTHORIZATION_CODE" \
        --data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
        --data-urlencode "code_verifier=YOUR_CODE_VERIFIER"
      ```

      ```bash Server app (with secret) theme={"theme":{"light":"github-light","dark":"vesper"}}
      curl -X POST https://api.simkl.com/oauth2/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -H "User-Agent: my-app-name/1.0" \
        -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
        --data-urlencode "grant_type=authorization_code" \
        --data-urlencode "code=AUTHORIZATION_CODE" \
        --data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
        --data-urlencode "code_verifier=YOUR_CODE_VERIFIER"
      ```
    </CodeGroup>

    The response:

    ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
    {
      "access_token":  "simkl_at_...",
      "token_type":    "Bearer",
      "expires_in":    604800,
      "refresh_token": "simkl_rt_...",
      "scope":         "media:read media:write"
    }
    ```

    **Read the `scope` field** rather than assuming you got what you asked for — see the scope trap below.
  </Step>

  <Step title="Use the token" icon="circle-check">
    Send `Authorization: Bearer simkl_at_...` on every request. It is good for 7 days; after that, [refresh it](/api-reference/oauth2-tokens) rather than sending the user back through consent.
  </Step>
</Steps>

## Common mistakes

Each of these is hard to diagnose from the error alone. Worth reading before you start rather than after.

<Warning>
  **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.
</Warning>

<Note>
  **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.
</Note>

<Warning>
  **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.
</Warning>

<Warning>
  **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.
</Warning>

## 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.

| Case                                                | Rule                                                                                                                                                         |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Exact string match**                              | The only rule for an ordinary host.                                                                                                                          |
| **Loopback** — `127.0.0.1`, `localhost` or `[::1]`  | Scheme, host and path must match; **the port is ignored**, so register it *without* a port and bind any free one at runtime. Available to every client type. |
| **`*.local`** — Mobile, desktop & browser apps only | Register the host as the literal string `*.local`. Any `<name>.local` host then matches, provided scheme, path **and port** match.                           |

Always rejected: a URI with no scheme, a URI with **any fragment** (`#...`), or an unsafe scheme such as `javascript:` or `data:`.

### Examples

| What you are building      | Register                                                                                                                                                    |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hosted web callback        | `https://your.app/auth/simkl` — if an auth library handles sign-in, register the route it uses, e.g. `https://your.app/api/auth/callback/simkl` for Auth.js |
| Mobile or desktop          | `yourapp://auth/simkl` — your app must handle its own URL scheme                                                                                            |
| Local development          | `http://127.0.0.1/auth/simkl`, `http://localhost/auth/simkl` or `http://[::1]/auth/simkl`                                                                   |
| Devices on a local network | `http://*.local/auth/simkl`, which matches `http://living-room-tv.local/auth/simkl`                                                                         |

<Note>
  **`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.
</Note>

<Warning>
  **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.
</Warning>

<Warning>
  **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.
</Warning>

<Warning>
  **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.
</Warning>

## 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`](/api-reference/simkl/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:

```js simkl-oauth-demo.mjs theme={"theme":{"light":"github-light","dark":"vesper"}}
import { createHash, randomBytes } from "node:crypto";
import { createServer } from "node:http";

const CLIENT_ID = process.env.SIMKL_CLIENT_ID;          // required
const CLIENT_SECRET = process.env.SIMKL_CLIENT_SECRET;  // Server apps & services only
const PORT = 8910;
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;  // register this exact string
const ISSUER = "https://simkl.com";
const UA = "simkl-demo/1.0";

if (!CLIENT_ID) throw new Error("Set SIMKL_CLIENT_ID first");

const b64url = (buf) => buf.toString("base64url");
const verifier = b64url(randomBytes(32));                 // never leaves this process
const challenge = b64url(createHash("sha256").update(verifier).digest());
const state = b64url(randomBytes(16));

console.log("\nOpen this in your browser:\n\n" + ISSUER + "/oauth2/authorize?" +
  new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: "code",
    scope: "media:read media:write",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }) + "\n");

createServer(async (req, res) => {
  const url = new URL(req.url, REDIRECT_URI);
  if (url.pathname !== "/callback") return res.writeHead(404).end();
  const q = url.searchParams;
  const done = (m) => { console.log(m); res.end(m); process.exit(0); };

  if (q.get("iss") !== ISSUER) return done("Rejected: response did not come from Simkl");
  if (q.get("state") !== state) return done("Rejected: state mismatch");
  if (q.get("error")) return done(`Authorization failed: ${q.get("error")}`);

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    code: q.get("code"),
    redirect_uri: REDIRECT_URI,      // byte-identical to the one sent above
    code_verifier: verifier,
  });
  if (CLIENT_SECRET) body.set("client_secret", CLIENT_SECRET);

  const r = await fetch("https://api.simkl.com/oauth2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": UA },
    body,
  });
  const token = await r.json();
  if (!r.ok) return done(`Token exchange failed: ${JSON.stringify(token)}`);
  console.log("scope granted:", token.scope);   // read this, do not assume

  const me = await fetch(
    `https://api.simkl.com/users/settings?client_id=${CLIENT_ID}&app-name=simkl-demo&app-version=1.0`,
    { headers: { Authorization: `Bearer ${token.access_token}`, "User-Agent": UA } },
  );
  done(`Signed in as ${(await me.json()).user.name}. You can close this tab.`);
}).listen(PORT, "127.0.0.1");
```

Register `http://127.0.0.1:8910/callback` as a redirect URI on your app, then:

```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
SIMKL_CLIENT_ID=your_client_id node simkl-oauth-demo.mjs
```

<Note>
  **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](https://datatracker.ietf.org/doc/html/rfc8252#section-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.
</Note>

## See also

<CardGroup cols={2}>
  <Card title="PKCE in AUTH V2" icon="shield-keyhole" href="/api-reference/oauth2-pkce">
    Generating the verifier and challenge, and the encoding mistakes to avoid.
  </Card>

  <Card title="Tokens and refresh" icon="key" href="/api-reference/oauth2-tokens">
    Lifetimes, the refresh grant, and revoking.
  </Card>

  <Card title="Scopes" icon="list-check" href="/api-reference/oauth2-scopes">
    `media:read` and `media:write`, and what happens when you get it wrong.
  </Card>

  <Card title="POST /oauth2/token" icon="code" href="/api-reference/simkl/oauth2-token">
    Endpoint reference for all three grants.
  </Card>

  <Card title="Client libraries" icon="puzzle-piece" href="/api-reference/oauth2-libraries">
    Configure a stock OAuth library instead of hand-rolling this.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/conventions/errors#oauth2-errors">
    Every `/oauth2/*` error, including why `invalid_grant` is vague.
  </Card>
</CardGroup>
