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.

View as Markdown

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.

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.

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 show the same checks for each language: Node.js, .NET, Go, Python and 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.

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/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
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.

{
  "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.

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