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

# AUTH V2 overview

> Standards-compliant OAuth 2.0 with PKCE, scopes, refresh tokens, and a device flow. The recommended way to authenticate new apps.

**AUTH V2 is Simkl's standards-compliant OAuth 2.0 implementation, and the recommended path for every new integration.** It follows the specs closely enough that a stock OAuth library configured against it should work without Simkl-specific patches.

If you are maintaining an existing app built on [AUTH V1](/api-reference/auth-v1), **it is being retired — expected to stop working around April 2027.** Start with [Migrating from V1 to V2](/guides/migrating-v1-to-v2); for most apps the work is confined to your auth code, and we are happy to help if yours is harder than that.

<Warning>
  **V2 apps are authenticated-only for anything user-specific.** There is no general anonymous `client_id`-only access the way V1 allows — a V2 app that calls a user-scoped endpoint without a bearer token gets:

  ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
  { "error": "user_token_required", "code": 401, "message": "This client_id requires a user access token on every request" }
  ```

  If your app has a browsing mode that today works without a signed-in user, check it against the exceptions below before you [migrate](/guides/migrating-v1-to-v2). Anything beyond plain catalog lookups — search included — will need a signed-in user.
</Warning>

<Note>
  **Catalog data is the exception — it needs no user token, even on V2:**

  * Movie, show and anime summaries by Simkl ID — [`/movies`](/api-reference/simkl/get-movie), [`/tv`](/api-reference/simkl/get-tv-show), [`/anime`](/api-reference/simkl/get-anime)
  * Episode lists — [`/tv/episodes`](/api-reference/simkl/get-tv-episodes) and [`/anime/episodes`](/api-reference/simkl/get-anime-episodes)
  * The [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) data files on `data.simkl.in`

  These carry no per-user state, so they are exempt **by design** rather than as a side effect of caching — a cache miss behaves the same way. They also [do not count against your quota](/resources/rate-limits#what-does-not-count).

  **"No user token" does not mean "no parameters".** Every request still carries `client_id`, `app-name` and `app-version`, plus a `User-Agent` header — see [Headers and required parameters](/conventions/headers). The exception is about the `Authorization` header only.

  **Everything else requires a user token**, including search, [`/redirect`](/api-reference/redirect), and every sync, scrobble, ratings, custom-lists and user-settings endpoint.
</Note>

## Which flow do I need?

<Columns cols={2}>
  <Card title="OAuth flow" icon="lock" href="/api-reference/oauth2-authorization-code" horizontal>
    For **web apps, mobile apps, SPAs, desktop binaries and browser extensions** — anything that can open a browser. PKCE is mandatory for all of them.
  </Card>

  <Card title="Device / PIN flow" icon="tv" href="/api-reference/oauth2-device" horizontal>
    For **TVs, consoles, watches, CLIs and media-server plugins** — anything without a usable browser or keyboard. Show an 8-character code, the user types it on their phone.
  </Card>
</Columns>

There is no third option, and which one you use depends on the device, not on whether you have a `client_secret`. If you have not registered the app yet, [**Set up authentication**](/api-reference/auth) walks the decisions in order — client type, secret, flow, scope — and is the page to read first.

## You need a new `client_id`

<Warning>
  **A V1 `client_id` cannot be upgraded to V2. Register a new app.** There is no conversion, no switch in developer settings, and no way to enable V2 on existing credentials — V2 apps are a separate registration with a **new `client_id`** (and, for server apps, a new `client_secret` shown to you once).

  Sending V1 credentials to a `/oauth2/*` endpoint returns:

  ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
  { "error": "invalid_client", "error_description": "This client_id is not enabled for OAuth 2.0" }
  ```

  If you see that while holding credentials that work perfectly against the V1 endpoints, nothing is wrong with your request — you need a V2 app. [Register one](https://simkl.com/settings/developer/).
</Warning>

Your existing V1 app keeps working while you migrate, so you can run both side by side: new sign-ins through the V2 app, existing users still served by the V1 one until they move. That is the recommended way to do it — see [Migrating from V1 to V2](/guides/migrating-v1-to-v2).

## How many client IDs do I need?

**One per app type you actually use** — not one per platform. A client type is fixed at registration, so a single registration cannot cover two of them.

<Columns cols={2}>
  <Card title="Everything through your backend" icon="server" horizontal>
    **One client ID.** Your backend signs users in, stores tokens and makes every API call. A mobile or TV interface that talks only to your backend does not need its own registration — it never touches Simkl directly.
  </Card>

  <Card title="Different types in one product" icon="id-card" horizontal>
    **Two or three client IDs.** Parts of your product sign in differently — an app callback here, a PIN-style device flow there, a server callback somewhere else. Each type needs its own registration.
  </Card>
</Columns>

The question is not "how many platforms do I ship?" but "how many *different ways* does my code obtain a token?". If every platform funnels through one backend, that is one way and one client ID.

<Accordion title="Example: a product with a mobile app, a TV app, and a backend">
  Three parts, three different sign-in mechanics, so three registrations:

  | Part                    | Type                           | Credentials              | How it signs in                                                                |
  | ----------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------ |
  | Mobile app or extension | Mobile, desktop & browser apps | Client ID **A**          | Receives the callback and exchanges the code on the user's device with PKCE.   |
  | TV app or CLI           | TV, devices & command line     | Client ID **B**          | Displays a code, polls while the user approves it in a browser.                |
  | Backend                 | Server apps & services         | Client ID **C** + secret | Receives the callback and exchanges the code with PKCE and its private secret. |

  Start with the type you need now and add the others when you need them — there is no benefit to registering all three up front.
</Accordion>

<Warning>
  **A user access token only works with the `client_id` it was issued for.** Registrations are entirely separate: a token minted for client ID A is rejected by B and C.

  So a user of a multi-client product **authorizes each one independently**. Someone who signs in on your mobile app has not signed in on your TV app — that is a separate grant, and they will see a separate consent screen.

  This is the strongest argument for the single-backend shape above when it fits your product: one registration, one grant per user, one place tokens live.
</Warning>

<Note>
  **The three-registration example above fits inside the smallest allowance.** Unverified apps are capped at **3 on Free, 5 on PRO and 10 on VIP** — counted **separately for AUTH V1 and AUTH V2**, so the V1 apps you are [migrating](/guides/migrating-v1-to-v2) from never consume a V2 slot.

  Verified apps do not count at all, and verification is free on every plan. See [Set up authentication](/api-reference/auth#client-types).
</Note>

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

## What you get back

Every successful exchange returns the same shape, whichever flow produced it:

```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"
}
```

| Field           | Meaning                                                                                                                                                                                                                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `access_token`  | Send as `Authorization: Bearer ...` on every request. Valid for **7 days**.                                                                                                                                                                                                                            |
| `token_type`    | Always the string `Bearer`, capitalised. [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5.1) defines this value as case-insensitive, so compare it that way rather than exact-matching. The casing you send on the `Authorization` header is separate, and is also case-insensitive. |
| `expires_in`    | Seconds until the access token expires — 604800, i.e. 7 days.                                                                                                                                                                                                                                          |
| `refresh_token` | Exchange for a fresh access token. Valid for **180 days**, and the window slides forward every time you use it.                                                                                                                                                                                        |
| `scope`         | Either `media:read` or `media:read media:write`. See [Scopes](/api-reference/oauth2-scopes).                                                                                                                                                                                                           |

Both tokens are 43 characters and carry a recognisable prefix — `simkl_at_` for access, `simkl_rt_` for refresh. Size your storage columns accordingly, and treat both as secrets.

<Warning>
  **Credentials found on GitHub are revoked automatically.** The `simkl_` prefixes exist so that automated secret scanning can recognise a leaked Simkl credential on sight, and each one carries a checksum so a scanner can tell a real credential from a random string before acting.

  If a **client secret**, **access token** or **refresh token** is committed to GitHub, it is revoked — **including in private repositories**. Never commit or paste one, and treat a leak as something that will be caught rather than something you might get away with.

  Your **`client_id` is different: it is public and safe to ship.** Putting it in your source code, your JavaScript bundle or a public repository is expected for every client type — that is what it is for. Only the secret and the user tokens need protecting.

  The reason it is safe is structural: **a V2 `client_id` on its own reaches nothing worth stealing.** Every user-scoped endpoint refuses it outright without an access token, so there is no library to read, nothing to write, and no per-user quota for a stranger to burn through. The only thing it still opens is public catalog data — the same summaries and data files anyone can fetch — which is cached, free, and does not count against anyone's allowance.
</Warning>

<Tip>
  **If a secret does leak, rotate it rather than registering a new app.** A **Server apps & services** registration can regenerate its secret from developer settings; the new one is shown once, the old one dies immediately, and **existing user grants survive** — your users do not have to re-authorize. The secret only authenticates your app at the token endpoint, so rotating it stops a leaked secret being used to refresh without disturbing anyone who has already connected.
</Tip>

## Token lifetimes at a glance

| Credential         | Lifetime              | Notes                                                                                                                                            |
| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Access token       | 7 days                | Short by design. Refresh rather than re-authorizing.                                                                                             |
| Refresh token      | 180 days, **sliding** | The clock resets every time you use it, so an app in regular use never needs re-consent. Non-rotating — the same string comes back each refresh. |
| Authorization code | 10 minutes            | Single-use, and consumed even by a failed exchange.                                                                                              |
| Device code        | 15 minutes            | Poll every 5 seconds.                                                                                                                            |

Full detail, including the two refresh behaviours that surprise people, is on [Tokens and refresh](/api-reference/oauth2-tokens).

<a id="what-the-user-sees" />

## What the user sees

Your grant is not invisible to the person who approved it. [Connected Apps](https://simkl.com/settings/connected-apps/) shows every user, for every app they have connected:

|                                  |                                                                                                                          |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Auth version**                 | Your app is labelled `AUTH V1` or `AUTH V2`                                                                              |
| **What you are permitted to do** | Your [scope](/api-reference/oauth2-scopes) in plain words — *"Can read and update your media library"*, or *"Read only"* |
| **How much you are using**       | Requests today, yesterday and all-time, sortable against every other app they run                                        |
| **Their daily allowance**        | Today's total against their limit, and what is left                                                                      |
| **Expiry**                       | Which connections are lapsing soon                                                                                       |
| **Revoke**                       | One click, at any time                                                                                                   |

Two consequences worth designing for. **Asking for `media:write` when you only read is visible**, and sits next to apps that asked for less. And when a user says your app stopped syncing, that page settles it faster than any log you have: the [allowance is theirs and shared across every app they run](/resources/rate-limits#auth-v2-per-user), so the cause is frequently another integration entirely — which you cannot see and they can.

<a id="discovery" />

## Discovery — let your library configure itself

Simkl publishes a small JSON file listing its own endpoints and capabilities — the standard RFC 8414 "discovery document", at [`/.well-known/oauth-authorization-server`](https://simkl.com/.well-known/oauth-authorization-server). It describes **V2**.

Most OAuth libraries know how to read it, which means you do not have to configure the authorize URL, the token URL and the supported auth methods yourself. Give the library **one** setting instead — `https://simkl.com` — and it fetches the rest. Libraries usually call that setting the **issuer**:

<CodeGroup>
  ```js Node.js (openid-client v6) theme={"theme":{"light":"github-light","dark":"vesper"}}
  import * as client from "openid-client";

  // algorithm: "oauth2" is required. Without it the library looks for an
  // OpenID Connect document, and Simkl publishes the OAuth 2.0 one.

  // Mobile, desktop & browser apps / TV, devices & command line — no secret.
  // client.None() is required: the default is client_secret_post, which would
  // try to send a secret you do not have.
  const config = await client.discovery(
    new URL("https://simkl.com"),
    "YOUR_CLIENT_ID",
    undefined,
    client.None(),
    { algorithm: "oauth2" },
  );

  // Server apps & services — the third argument is a client_secret shorthand,
  // and the default client_secret_post is what you want, so leave the fourth out.
  const config = await client.discovery(
    new URL("https://simkl.com"),
    "YOUR_CLIENT_ID",
    "YOUR_CLIENT_SECRET",
    undefined,
    { algorithm: "oauth2" },
  );
  ```

  ```python Python (authlib) theme={"theme":{"light":"github-light","dark":"vesper"}}
  # Discovery lives on Authlib's framework integrations, which share one API --
  # swap flask_client for django_client or starlette_client as needed. The plain
  # requests_client OAuth2Session does not read metadata; set endpoints by hand there.
  from authlib.integrations.flask_client import OAuth

  oauth = OAuth()
  oauth.register(
      name="simkl",
      client_id="YOUR_CLIENT_ID",
      # Supplies authorize_url and access_token_url, so you do not set them.
      server_metadata_url="https://simkl.com/.well-known/oauth-authorization-server",
      client_kwargs={
          "scope": "media:read media:write",
          "code_challenge_method": "S256",        # PKCE; S256 is the only method Authlib supports
          "token_endpoint_auth_method": "none",   # no secret. For Server apps & services,
                                                  # drop this line and pass client_secret="..."
      },
  )
  ```

  ```yaml Java (Spring Security) theme={"theme":{"light":"github-light","dark":"vesper"}}
  # issuer-uri is all you need: Spring reads the discovery document and fills in
  # the endpoints itself. Write it with NO trailing slash -- "https://simkl.com/"
  # is a different issuer string and fails to resolve.
  spring:
    security:
      oauth2:
        client:
          registration:
            simkl:
              client-id:                    YOUR_CLIENT_ID
              client-secret:                YOUR_CLIENT_SECRET   # server apps only
              client-authentication-method: client_secret_basic  # use "none" if you have no secret
              authorization-grant-type:     authorization_code
              redirect-uri:                 "{baseUrl}/login/oauth2/code/{registrationId}"
              scope:                        media:read,media:write
          provider:
            simkl:
              issuer-uri: https://simkl.com
  ```
</CodeGroup>

<Note>
  **If your library looks for `/.well-known/openid-configuration`, point it at OAuth 2.0 instead.** Simkl is an OAuth 2.0 server, not an OpenID Connect provider, so the OIDC path returns `404` and the document you want lives at `/.well-known/oauth-authorization-server`. Libraries that try both in turn — Spring Security, for one — fall through on their own. Libraries that try only the one you selected need a flag: `{ algorithm: "oauth2" }` in openid-client, for example. A `404` during setup usually means the library is looking at the OIDC path and has no fallback.
</Note>

<Warning>
  **Give Spring the issuer with no trailing slash.** `issuer-uri: https://simkl.com` resolves; `https://simkl.com/` does not. RFC 8414 requires the `issuer` in the document to match the one you asked for character for character, and the two differ — so the trailing slash turns a working configuration into "Unable to resolve Configuration with the provided Issuer".

  **And PKCE is not automatic for a client with a secret.** Spring adds PKCE by default only for clients that have no secret, but Simkl requires it from *every* client type — so a Spring app configured with a `client-secret` reaches the token endpoint without a `code_verifier` and gets `400 invalid_request`. Opt in explicitly:

  ```java theme={"theme":{"light":"github-light","dark":"vesper"}}
  var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
  resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
  ```
</Warning>

If your library does not support discovery at all, nothing is lost — set `https://simkl.com/oauth2/authorize` and `https://api.simkl.com/oauth2/token` directly, as listed above. Discovery saves you typing, not capability.

<Warning>
  **The Node and Python snippets here were run against production on 2026-09-18** and configure themselves correctly from this document. The Java one is documentation-checked only. Neither covers the browser half of the flow, which needs a human — see [Client libraries](/api-reference/oauth2-libraries) for the per-library detail and the current status of each.

  If one of the above does not work for you, [tell us](https://discord.gg/MJsWNE4) and send the capture — that is how the V1 matrix got built.
</Warning>

## See also

<CardGroup cols={2}>
  <Card title="OAuth flow" icon="lock" href="/api-reference/oauth2-authorization-code">
    The full walkthrough with PKCE, including the failures that are hardest to diagnose.
  </Card>

  <Card title="Device / PIN flow" icon="tv" href="/api-reference/oauth2-device">
    RFC 8628, end to end — polling rules, expiry, and what the user sees.
  </Card>

  <Card title="Scopes" icon="key" href="/api-reference/oauth2-scopes">
    `media:read` and `media:write`, and why omitting `scope` gives you read-only.
  </Card>

  <Card title="PKCE in V2" icon="shield-keyhole" href="/api-reference/oauth2-pkce">
    S256 only. How to generate the verifier and challenge correctly.
  </Card>

  <Card title="Client libraries" icon="puzzle-piece" href="/api-reference/oauth2-libraries">
    Per-library configuration, and the three defaults that need changing.
  </Card>

  <Card title="Migrating from V1" icon="arrow-right" href="/guides/migrating-v1-to-v2">
    What actually changes, and what doesn't.
  </Card>

  <Card title="AUTH V1" icon="clock-rotate-left" href="/api-reference/auth-v1">
    The original flow. Being retired around April 2027.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/conventions/errors#oauth2-errors">
    The OAuth 2.0 error vocabulary, and the envelope that differs.
  </Card>
</CardGroup>
