Skip to content

Audiences and scopes

Every access token IntelliAuth issues carries two pieces of authorisation information: the audience (the API the token is for) and the scope (the permissions the token grants). Backends should verify both before serving a request.

The audience claim (aud) names the API the token was minted for. When your backend validates a token, it checks that the audience matches what this API expects. If a token issued for api.banking.cymmetri.com lands on a request to api.health.cymmetri.com, the validation rejects it — even though the token is otherwise perfectly valid.

// JWT payload (decoded)
{
"iss": "https://banking-cymmetri.intelliauth.local",
"sub": "usr_01HZX...",
"aud": "api.banking.cymmetri.com", // ← this token is for this API only
"scope": "openid profile read:transactions",
"exp": 1750000000,
"iat": 1749996400
}

The audience is set when the client requests a token. The IntelliAuth React SDK's getAccessToken({ audience: 'api.banking.cymmetri.com' }) mints a token for that audience. If your app talks to multiple APIs (your own + a third-party), you request a separate access token per audience — the SDK caches each one independently.

The scope claim names the permissions the token grants. It's a space-separated string of scope identifiers. Standard OIDC scopes:

  • openid — required for OIDC flows; grants identity claims.
  • profile — name, picture, locale, etc.
  • email — email + email_verified.

Plus any custom scopes your tenant defines for your API:

  • read:transactions — read-only access to transaction data.
  • write:settings — write access to user settings.
  • manage:applications — register and rotate applications.

When your backend gets a request, it checks two things:

  1. Audience matches.
  2. The scope claim contains every scope the requested action needs.
function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction) => {
const tokenScopes = (req.user.scope as string).split(' ')
if (!tokenScopes.includes(scope)) {
return res.status(403).json({ error: 'insufficient_scope', required: scope })
}
next()
}
}
app.get('/transactions', requireScope('read:transactions'), handler)
app.post('/settings', requireScope('write:settings'), handler)

Each application can declare which scopes it's allowed to request. This is a hard cap — even if a client asks for manage:applications, if its application registration doesn't include that scope in its allowlist, the platform refuses to mint a token with it. Set the allowlist when you register the application in the tenant admin console.

The right default: applications get the minimum scopes their feature set needs. Adding a scope to an application is a deliberate action by the tenant admin; the platform's audit log records who added it and when. This is how principle of least privilege gets enforced.

When a user signs in to an application that's requesting scopes for the first time (or scopes that haven't been previously consented to), they see a consent screen describing what permissions the application wants. They click Allow; the scopes are added to their consent record for that application; tokens then start including those scopes.

You don't have to write the consent screen — IntelliAuth renders it from the scope metadata in your tenant config. You do have to author the scope descriptions: each scope on the tenant has a human-readable name + description shown to end-users at consent time.

A common source of confusion: scope strings are claims in the token. Enforcing them is the backend's job. The platform mints the token with whatever scopes are validly requested; whether your /transactions endpoint actually checks for read:transactions is up to you.

The SDK helpers (requireScope above, equivalents in other SDKs) make this easy. The discipline is to actually use them everywhere — a token validly verified against the wrong scope is still authorised, which defeats the point.