---
title: Platform token returns 403 with "requires admin:read or admin:write scope"
path: troubleshooting/platform-token-403
status: published
---

# Platform token returns 403 with "requires admin:read or admin:write scope"

## Symptom

Your service obtains a token via `client_credentials` at `/api/v1/platform/oauth/token` and the call succeeds. But when the service uses the token to call an admin endpoint (`/api/v1/admin/...`), it gets:

```
HTTP/1.1 403 Forbidden
{"detail":"Platform token requires admin:read or admin:write scope"}
```

## Cause

The admin API gates every endpoint on the JWT carrying `admin:read` or `admin:write` in its `scope` claim. A platform token's scopes come from one of two places:

1. The `scope` form parameter you sent on the `client_credentials` request, or
2. If you sent none, the application's `allowed_scopes` column (the registered scope superset).

If neither path put `admin:read` (or `admin:write`) into the token, the admin API rejects.

## Fix

Two things have to be true:

### 1. The application's `allowed_scopes` must include the admin scope

Check your application's row in the admin UI (Applications → your app → Settings → Allowed Scopes). If `admin:read` isn't ticked, the application can never produce a token with that scope, regardless of what you ask for.

If the admin UI's scope picker doesn't show `admin:read` as a togglable option, the picker is limited to its hardcoded list — bump the application via API:

```bash
curl -X PATCH "$SCAIKEY/api/v1/admin/applications/<app_id>" \
  -H "Authorization: Bearer <super_admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"allowed_scopes":["openid","admin:read","admin:write"]}'
```

(Include any other scopes the app already needs — `allowed_scopes` is a replacement, not a merge.)

### 2. The token request must carry that scope

Either include it explicitly:

```bash
curl -X POST "$SCAIKEY/api/v1/platform/oauth/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  -d "scope=admin:read"
```

Or omit the `scope` parameter entirely so the token inherits the full `allowed_scopes` list.

## Verify

Decode the access token and confirm `admin:read` is in `scope`:

```bash
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
```

The `scope` claim should be a space-separated string containing `admin:read` (and/or `admin:write`).

## Gotcha: stale cached tokens

Bumping the application's `allowed_scopes` does not retroactively widen existing tokens. Issued JWTs are immutable — they carry whatever scopes they had at mint time. After changing the app's scopes, force your service to request a fresh token (clear any in-memory cache).

## Related

- [Concepts → Tokens and scopes](/docs/scaikey/concepts/tokens-and-scopes#scaikey-admin-scopes) — the full scope list.
- [Concepts → Applications](/docs/scaikey/concepts/applications) — application configuration including `allowed_scopes`.
