# Protect an API (validate access tokens)

> Validate OneiD access tokens in your API by checking the signature, issuer, expiry and scope, and answer with the right 401 or 403.

Source: https://oltinid.com/docs/guides/protect-an-api/ · Section: Guides · All OneiD documentation: https://oltinid.com/llms.txt

An API protected by OneiD accepts a bearer access token on each request and checks it locally. OneiD access tokens are signed JWTs, so your API needs no call to OneiD per request: it verifies the signature with OneiD's published keys and then checks the claims.

## What an access token looks like

OneiD access tokens are JWTs signed with RS256. They are not encrypted. The header carries a `kid` that names the signing key in OneiD's JWKS.

| Claim | Meaning |
|---|---|
| `iss` | The OneiD issuer, `https://YOUR_ONEID/`, with the trailing slash. |
| `sub` | The user's ID. For a client credentials token, the client ID. |
| `exp`, `iat` | Expiry and issue time, in seconds since 1970. |
| `scope` | The granted scopes, separated by spaces. |
| `client_id` | The client the token was issued to. |
| `role` | The user's roles, when the `roles` scope was granted. |

Scope-released profile, email and phone claims can also be present. Ignore claims you do not know.

> **Warning:** OneiD access tokens have no `aud` claim. Do not require an audience. A library that checks the audience by default rejects every OneiD token; turn that check off and check the scope instead.

## Validate a token step by step

Your API does the following for every request. Use a maintained JWT library; it does most of these steps for you.

1. Read the token from the `Authorization: Bearer <token>` header. Reject the request if the header is missing or uses another scheme.
2. Check that the header `alg` is `RS256`. Reject `none` and every other algorithm.
3. Find the key whose `kid` matches the token's `kid` in OneiD's JWKS and verify the signature.
4. Check that `iss` equals the `issuer` from the discovery document exactly, including the trailing slash: `https://YOUR_ONEID/`.
5. Check `exp`, and `nbf` if present, against the current time. Allow a small clock skew, such as a minute at most.
6. Check that the space-separated `scope` claim contains the scope this endpoint requires.
7. If the endpoint needs a role, check the `role` claim.

### Get the keys from discovery

Read the discovery document once at start-up and take `jwks_uri` and `issuer` from it. Do not hard-code them.

```http
GET /.well-known/openid-configuration HTTP/1.1
Host: YOUR_ONEID
```

The discovery document gives `jwks_uri` as `https://YOUR_ONEID/.well-known/jwks`.

### Cache the JWKS and refresh on an unknown key

Cache the JWKS. When a token arrives with a `kid` that is not in your cache, fetch the JWKS again once and retry. Limit how often you re-fetch, so that tokens with made-up `kid` values cannot make your API call OneiD on every request.

OneiD publishes a new signing key in the JWKS before it starts signing with it, and keeps a retired key in the JWKS for 30 days. An API that caches the JWKS and re-fetches on an unknown `kid` keeps working through key rotation without changes.

### Example in Node.js

This example uses the `jose` library. `createRemoteJWKSet` caches the keys and re-fetches them when it sees an unknown `kid`.

```js
import { createRemoteJWKSet, jwtVerify } from 'jose';

const discovery = await fetch('https://YOUR_ONEID/.well-known/openid-configuration')
  .then((r) => r.json());
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));

export async function verifyAccessToken(token, requiredScope) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: discovery.issuer,   // "https://YOUR_ONEID/" with the slash
    algorithms: ['RS256'],
    clockTolerance: 60,         // seconds
    // no audience option: OneiD access tokens have no aud claim
  });
  const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ') : [];
  if (!scopes.includes(requiredScope)) {
    const err = new Error('insufficient_scope');
    err.status = 403;
    throw err;
  }
  return payload;
}
```

The [API quickstarts](https://oltinid.com/docs/quickstarts/) show the same checks for each language: [Node.js](https://oltinid.com/docs/quickstarts/api-node/), [.NET](https://oltinid.com/docs/quickstarts/api-dotnet/), [Go](https://oltinid.com/docs/quickstarts/api-go/), [Python](https://oltinid.com/docs/quickstarts/api-python/) and [Java](https://oltinid.com/docs/quickstarts/api-java/).

## Use one scope per API

Because access tokens have no audience, the scope is what ties a token to an API. A token that carries `orders.read` is accepted by every API that accepts `orders.read`.

- Ask your OneiD administrator to create distinct API scopes for each API, for example `orders.read` and `orders.write` for the orders API and `invoices.read` for the invoices API.
- Require one of your own API's scopes on every endpoint. Never accept a token only because its signature and issuer are valid.
- Do not reuse another API's scope names, and do not accept identity scopes such as `openid` or `profile` as proof of access to your API.

Then a token issued for the invoices API cannot be used at the orders API, because it does not carry an orders scope.

> **Note:** API scopes exist only after an administrator creates them. They appear in the access token's `scope` claim and add no other claims. See [Scopes, claims and roles](https://oltinid.com/docs/guides/scopes-claims-roles/).

## Answer with 401 or 403

Follow RFC 6750 so that clients can tell what went wrong.

| Situation | Status | `WWW-Authenticate` header |
|---|---|---|
| No token | 401 | `Bearer` |
| Token malformed, expired, wrong issuer or bad signature | 401 | `Bearer error="invalid_token"` |
| Valid token without the required scope | 403 | `Bearer error="insufficient_scope", scope="orders.read"` |
| Valid token and scope, but the user lacks the role your endpoint needs | 403 | Not required |

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
```

```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders.write"
```

Keep `error_description` short and do not echo the token.

## Use roles for authorisation

When the client requests the `roles` scope and the user has roles, the access token carries a `role` claim. With one role it can be a single string; with several roles it is an array. Accept both forms.

```json
{
  "iss": "https://YOUR_ONEID/",
  "sub": "8d0c6a3e-2f4b-4c1e-9a77-1b2c3d4e5f60",
  "scope": "openid roles orders.read",
  "role": ["OrderViewer", "Support"]
}
```

Roles are assigned to users in OneiD or mapped from directory or provider groups. See [Scopes, claims and roles](https://oltinid.com/docs/guides/scopes-claims-roles/).

Client credentials tokens represent a service, not a user. Their `sub` is the client ID. If an endpoint must only serve users, or only serve one service, check `sub` or `client_id` as well.

## Revocation and introspection

OneiD can revoke tokens: when a user signs out of an application, when the user's password changes, when an administrator removes a role or locks the user, and in other cases listed in [Tokens](https://oltinid.com/docs/reference/tokens/). An API that validates JWTs locally does not see a revocation until the token expires.

You have two options:

- **Short-lived access tokens with local validation.** This is the recommended pattern for most APIs. Access tokens live 1 hour by default, and an administrator can shorten the lifetime for a client.
- **Introspection.** An API that must see revocation immediately can call `POST /connect/introspect`. Introspection requires a confidential client and only answers for tokens issued to that same client, so it does not fit an API that serves tokens issued to other clients.

## Learn more

- [Tokens](https://oltinid.com/docs/reference/tokens/)
- [Scopes, claims and roles](https://oltinid.com/docs/guides/scopes-claims-roles/)
- [Client credentials for services](https://oltinid.com/docs/guides/client-credentials/)
- [Errors and troubleshooting](https://oltinid.com/docs/reference/errors/)
