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

# Scopes (AUTH V2)

> media:read and media:write — how to request them, how they are enforced, and the two ways to silently end up read-only.

AUTH V2 has exactly two scopes.

| Scope         | Grants                                                                   |
| ------------- | ------------------------------------------------------------------------ |
| `media:read`  | Read the user's library, history, ratings, settings, playback and lists. |
| `media:write` | Everything above, **plus** writes. `media:write` implies `media:read`.   |

Because write implies read, there are only two meaningful states, and the token response says which one you got:

```
"scope": "media:read"
"scope": "media:read media:write"
```

<Note>
  **Why the examples ask for both, when write already implies read.**

  Requesting `scope=media:write` on its own works and grants exactly the same access — you are not required to list both. We show `media:read media:write` for two practical reasons:

  * **It matches what comes back.** The token response always reports `media:read media:write` when write is granted. Asking for the same string you will be handed makes a request-versus-response comparison trivial, which matters because [checking the returned scope](#two-ways-to-silently-get-a-read-only-token) is the defence against a silent downgrade.
  * **It is what OAuth expects.** `scope` is a space-delimited list of everything you want, so listing both is the conventional form and what a stock library will produce.

  Either works. If you prefer the shorter form, use it — just compare against the response rather than against what you sent.
</Note>

## Which one do you need?

If your app only displays things, ask for `media:read`. If it scrobbles, marks items watched, rates, or changes lists, ask for `media:write`.

Requesting write when you only read is not harmful, but it makes your consent screen ask for more than you need, and some users decline on that basis. Ask for what you use.

## Requesting a scope

Pass it on the authorize URL, space-separated (so `%20`-encoded in a query string):

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

Or on the device request:

```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth2/device \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "User-Agent: my-app-name/1.0" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "scope=media:read media:write"
```

## Two ways to silently get a read-only token

Both of these produce a working token that cannot write, with no error at any point in the flow. The failure shows up much later, as a `403` on your first write — usually in production, usually from a user report.

<Warning>
  **Omitting `scope` grants `media:read` only.** Not both. This applies to the authorize endpoint and the device endpoint alike.

  A write-capable app must ask for `media:write` explicitly. There is no "default to everything" behaviour — the safe default, but not the one most people assume.
</Warning>

<Warning>
  **An unrecognised scope string is silently downgraded to read-only.** Scope parsing looks for the exact lowercase token `media:write`. If it is not there, write is simply not granted and no error is raised.

  So `media:wrote`, `media.write`, a bare `write`, and `MEDIA:WRITE` (wrong case) all get you a read-only token that works perfectly until the first write.
</Warning>

<Note>
  **The separator is more forgiving than the spelling.** Because the check is for the presence of `media:write` rather than a parse of the list, a separator mistake like `media:read,media:write` still grants write — the token is in there either way.

  That asymmetry is worth knowing so you debug in the right place: if writes are failing, suspect the **spelling or case** of `media:write`, not your delimiter. Send the space-separated form regardless — it is what the spec calls for and what we will keep supporting.
</Note>

**The defence for both is the same, and it is one line:** read the `scope` field in the token response and compare it to what you asked for.

```js theme={"theme":{"light":"github-light","dark":"vesper"}}
if (needsWrite && !token.scope.includes("media:write")) {
  throw new Error(`Expected write access, got scope: ${token.scope}`);
}
```

Failing loudly at authorization time turns a confusing production `403` into an obvious configuration bug on your own machine.

## How scope is enforced

Enforcement is by request shape rather than by a per-endpoint list:

* **A `GET` with no request body** needs `media:read`.
* **Everything else** needs `media:write` — every other method, every request that carries a body, and all of `/scrobble` and `/checkin` even on `GET`.

A request that exceeds its token's scope is rejected with `403` and an `insufficient_scope` error, along with a `WWW-Authenticate` header naming the scope required.

<Warning>
  **A `GET` that carries a body counts as a write.** Attaching a payload to a `GET` is the one way to get a surprising `403` on a read-only token — and some HTTP helpers do it for you if you hand them a JSON argument without thinking about the method. If a read is failing with `insufficient_scope`, check whether your client is sending a body it does not need.

  The rule is deliberately blunt. Scope is decided before anything looks at which endpoint you called, so a request that carries a payload is treated as a write on principle rather than judged case by case.
</Warning>

## Changing scope later

You cannot widen a grant by refreshing. On the [refresh grant](/api-reference/oauth2-tokens):

* a **narrower** scope is ignored and the original is reissued
* a **wider** scope is rejected with `invalid_scope`

To change what your app can do, send the user through authorization again with the scope you want. That creates a new, independent grant — it does not modify or replace the existing one, so discard the old tokens yourself if you no longer want them.

## See also

<CardGroup cols={2}>
  <Card title="OAuth flow" icon="lock" href="/api-reference/oauth2-authorization-code">
    Where the `scope` parameter goes.
  </Card>

  <Card title="Tokens and refresh" icon="key" href="/api-reference/oauth2-tokens">
    Why refreshing cannot change your scope.
  </Card>

  <Card title="Migrating from V1" icon="arrow-right" href="/guides/migrating-v1-to-v2">
    Scopes are new in V2 — what that means when you port existing code.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/conventions/errors">
    The full error vocabulary, including `insufficient_scope`.
  </Card>
</CardGroup>
