---
title: Tokens and scopes
path: concepts/tokens-and-scopes
status: published
---

# Tokens and scopes

## Token format

Every token ScaiKey issues is a signed JWT. Signing algorithm is `RS256` by default; the platform can be configured for `ES256`. Public keys are published at the JWKS endpoint and rotated on a schedule.

Three token shapes:

- **Access token** — bearer credential for API calls. Carries `sub`, `aud`, `scope`, and a few platform-specific claims.
- **ID token** — OIDC user-identity assertion. Issued when `openid` is in the requested scope. Contains identity claims (`email`, `name`, `groups`, etc.) keyed off the requested scopes.
- **Refresh token** — opaque (not a JWT), one-time-use, hash-stored in the database. Exchanged for a fresh access token at the token endpoint.

## Access token claims

A standard ScaiKey access token looks like this:

```json
{
  "iss": "https://scaikey.scailabs.ai/tenants/{tenant-id}",
  "aud": "<client_id of the audience app>",
  "sub": "<user_id or client_id>",
  "scope": "openid profile email",
  "tenant_id": "tnt_widget0001",
  "exp": 1747584000,
  "iat": 1747580400
}
```

Additional claims depending on grant and app type:

| Claim | When set | Meaning |
|---|---|---|
| `tenant_id` | Tenant-scoped flows | The tenant the principal belongs to |
| `partner_id` | When the user has a partner role | Set on `partner_admin` and on partner-level tokens |
| `role` | Admin tokens | `super_admin` / `partner_admin` / `tenant_admin` |
| `client_id` | All flows | The OAuth client_id that requested the token |
| `app_scope` | Platform tokens | `GLOBAL` / `PARTNER` / `TENANT` — the app's scope level |
| `platform_token` | Set true on `client_credentials` tokens minted by GLOBAL apps | Marker for the admin API to grant cross-tenant access |
| `token_type` (claim) | `client_credentials` grant | `"client_credentials"` — for consumers that distinguish service vs user tokens |
| `act` | Tokens minted via Token Exchange | RFC 8693 delegation chain (who exchanged on whose behalf) |

**Note on `token_type`:** the OAuth response envelope also has a field called `token_type`, which is always `"Bearer"` (it describes how the token should be used, not what kind of token it is). These are different things at different layers — the response field is RFC 6749 §5.1; the JWT claim is a ScaiKey extension.

## Scopes

### Standard OIDC scopes

| Scope | Claims released |
|---|---|
| `openid` | Required for OIDC. Triggers ID token issuance. |
| `profile` | `name`, `given_name`, `family_name`, `preferred_username`, `picture`, `locale`, `zoneinfo` |
| `email` | `email`, `email_verified` |
| `groups` | `groups` — array of group slugs the user belongs to |
| `offline_access` | Requests a refresh token |

### ScaiKey admin scopes

Used by service integrations that call ScaiKey's own admin API. Granted only to applications whose `allowed_scopes` list includes them.

| Scope | Grants |
|---|---|
| `admin:read` | Read access to the entire admin API (partners, tenants, users, groups, apps, sessions, audit) |
| `admin:write` | Write access to the admin API |
| `users:read`, `groups:read` | Narrower read-only scopes; useful when you want a service to mirror just one resource type |
| `scaicore:view`, `scaicore:manage` | Reserved for ScaiCore-tier operations |

These are platform-token scopes — they only do anything meaningful on JWTs minted via `client_credentials` for `GLOBAL` apps. A user JWT doesn't grant admin access just because the scope is present; the user must also have the appropriate role.

### How scopes are constrained

The token endpoint never grants more than three things permit, simultaneously:

1. What the application's `allowed_scopes` column lists (registered).
2. What the request asked for (`scope=...` parameter).
3. For Token Exchange: what the subject token already had (`subject_scopes`).

If you ask for a scope your app isn't registered for, the token comes back narrower (or, in Token Exchange, with no overlap → `invalid_scope` error).

## Token lifetimes

| Token | Default | Where it's set |
|---|---|---|
| Access token | 3600 s (1 h) | `applications.token_lifetime` on the *audience* app — configurable per application |
| Refresh token | 30 days | `applications.refresh_token_lifetime` |
| Authorization code | 600 s (10 min) | Platform setting (`OIDC.authorization_code_ttl`) |
| ID token | Same as access | Always matches the access token's `exp` |

A super_admin can change `token_lifetime` per application via `PATCH /api/v1/admin/applications/{id}`. The change applies to the next token minted; existing tokens keep their original `exp`.

For Token Exchange: the exchanged token's lifetime is the **target's** `token_lifetime`, not the subject's. A long-running async worker can therefore exchange a near-expiry user token for a fresh full-lifetime audience-restricted token.

## Verifying tokens

Standard JWT verification using the public key published at the JWKS endpoint. Steps:

1. Fetch JWKS from `$SCAIKEY/api/v1/platform/.well-known/jwks.json` (or the tenant equivalent).
2. Read the `kid` header from the JWT to pick the right key.
3. Verify signature.
4. Validate `iss`, `aud`, `exp`, `nbf` (if present).
5. Apply scope checks based on your endpoint's policy.

Don't cache the JWKS forever; refresh periodically or on any `kid` you don't recognize. Keys are rotated occasionally; old keys remain in the JWKS during their grace period so in-flight tokens still validate.
