Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tokens and claims

Audiences and the aud claim

NanoIDP follows the OpenID Connect / OAuth specs for the aud claim:

  • ID Token: aud is the requesting client's client_id (OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configure additional_audiences on a client to append extra audiences; if this produces more than one distinct audience value, aud is emitted as an array and NanoIDP also emits an azp claim equal to the client_id so you can exercise authorized-party validation.
  • Access Token: aud is the resource audience from oauth.audience (RFC 9068 §2.2), independent of the client.

Access Token

The access token aud is the resource audience (oauth.audience). The user's attributes from users.yaml are carried both as individual claims and flattened into authorities via the configured authority_prefixes:

{
  "iss": "http://localhost:8000",
  "sub": "admin",
  "aud": "my-app",
  "iat": 1704100000,
  "exp": 1704103600,
  "roles": ["USER", "ADMIN"],
  "groups": ["ADMINISTRATORS", "EVERYONE"],
  "tenant": "default",
  "identity_class": "INTERNAL",
  "entitlements": ["ADMIN_ACCESS", "USER_MANAGEMENT"],
  "authorities": [
    "ROLE_USER",
    "ROLE_ADMIN",
    "GROUP_ADMINISTRATORS",
    "GROUP_EVERYONE",
    "IDENTITY_INTERNAL",
    "ENT_ADMIN_ACCESS",
    "ENT_USER_MANAGEMENT",
    "ACL_READ",
    "ACL_WRITE"
  ]
}

The exp and extra parameters

Two form parameters of /token, on every grant, exist for testing and have no counterpart in RFC 6749:

  • exp is the lifetime in minutes, 1 to 1440, of the access token and of any ID token issued with it, in place of oauth.token_expiry_minutes. It does not affect refresh tokens (oauth.refresh_token_expiry_minutes). It is not the JWT exp claim: the claim is computed from it.
  • extra is a JSON object of custom claims to add to the access token (never to the ID token or the refresh token). It adds, it never changes: a request that names a claim the grant or the user store decides is refused with invalid_request and the names, before the grant runs, so an authorization code, a device code or a refresh token is not consumed by it. Refused names: the registered claims (iss, sub, aud, exp, iat, nbf, jti), the protocol claims (client_id, scope, token_use, azp, nonce, auth_time, amr, at_hash, req_*), the claims the server reads back (token_type, rt_family, resource), and the claims about the user (roles, authorities, tenant, groups, entitlements, identity_class, source_acl, attributes, username and the OIDC standard claims such as email or name). So extra={"exp": ...} is refused where exp=15 is a lifetime. A token for a user with other roles is obtained by creating that user (a runtime user, or MCP create_user), not through extra.
POST /token  grant_type=password ... exp=15 extra={"tenant_plan":"trial"}
-> access token with tenant_plan and a 15 minute lifetime

POST /token  grant_type=password ... extra={"sub":"root","roles":["ADMIN"]}
-> 400 {"error":"invalid_request","error_description":"'extra' cannot set: roles, sub"}

The MCP tool generate_token applies the same rule to its extra_claims argument.

A client credentials token

A token from the client_credentials grant has no user behind it: the client acts on its own behalf (RFC 6749 §4.4). Its subject is the client (RFC 9068 §2.2), and it carries no user's attributes:

{
  "iss": "http://localhost:8000",
  "sub": "demo-client",
  "client_id": "demo-client",
  "aud": "my-app",
  "scope": "profile",
  "token_use": "access",
  "iat": 1704100000,
  "nbf": 1704100000,
  "exp": 1704103600,
  "jti": "..."
}
  • aud is the resource when the request names one, as for any access token. No refresh token and no ID token are issued.
  • These claims are the grant's: the extra parameter can add other claims and nothing else, as on every grant (see above); a request that names one of them, or a claim about a user, is refused.
  • /userinfo answers such a token with {"sub": ...} alone, and /introspect names no username, nor the openid scope it reports for a user token issued without one: a token whose sub equals its client_id is never taken as a user's, even if a user has the client's name. A user named like a client is reported with a warning at load.
  • Until #445 this token was issued for the default_user of users.yaml, with that user's roles; default_user is deprecated and has no effect.

ID Token

Issued when the openid scope is requested. Its aud is the client's client_id:

{
  "iss": "http://localhost:8000",
  "sub": "admin",
  "aud": "demo-client",
  "iat": 1704100000,
  "exp": 1704103600,
  "nonce": "..."
}

ID Tokens also carry auth_time (when the end-user actually authenticated, preserved across refreshes, OIDC Core §12.2) and at_hash (binding to the access token issued alongside, §3.1.3.6). By default they do not carry email or other profile claims - see Where do the email / profile claims come from? below.

amr

amr (Authentication Methods References, OIDC Core §2, values from RFC 8176 §2) is present on an ID Token once login.totp is on (#348, see Second factor (TOTP)). Of the interactive login surfaces, only /authorize and the device flow mint ID Tokens, so those are the two surfaces that carry it; /login and /saml/sso authenticate the same way but feed the dashboard session and the SAML context instead, and /authorize never reuses a /login session - it re-authenticates on its own each time, TOTP check included:

  • ["pwd"] - a password login with no second factor.
  • ["pwd", "otp"] - a password login followed by a valid TOTP code.

It is absent with login.totp off (the feature is opt-in on the wire too: a deployment that never turned it on sees no new claim), under login.mode: persona (identity selection checks no password, so pwd would be a claim about a check that never happened - metadata never lies), and on the OAuth password grant, which #348 leaves untouched. It is preserved across a refresh (carried in the refresh token payload, the same way auth_time is) and is never emitted on an access token, only the ID Token. amr is a reserved claim name: a claims request parameter (see below) cannot set it.

If additional_audiences produces more than one distinct audience value, aud becomes an array and azp is added:

{
  "iss": "http://localhost:8000",
  "sub": "admin",
  "aud": ["multi-aud-client", "https://api.example.com", "urn:service:billing"],
  "azp": "multi-aud-client",
  "iat": 1704100000,
  "exp": 1704103600
}

Where do the email / profile claims come from?

A common surprise: you request scope=openid email, then look for an email claim inside the ID Token or access token and don't find it.

That is expected. The standard OpenID Connect profile/email claims (email, email_verified, preferred_username; plus NanoIDP-specific and custom claims where configured) are not embedded in the tokens. For the authorization code flow they are served from the UserInfo endpoint, using the access token (OpenID Connect Core 1.0 §5.4). The discovery document advertises what is available - scopes_supported (configurable, default openid, profile, email, offline_access - see "Registered scopes" in the configuration reference) and claims_supported - but those scope-based claims are returned from GET /userinfo, not from the tokens themselves.

curl 'http://localhost:8000/userinfo' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
{
  "sub": "admin",
  "email": "admin@example.org",
  "email_verified": true,
  "preferred_username": "admin",
  "roles": ["USER", "ADMIN"],
  "groups": ["ADMINISTRATORS", "EVERYONE"],
  "tenant": "default",
  "identity_class": "INTERNAL"
}

Scope gating

Under the default dev profile, /userinfo returns these claims unconditionally. Under the stricter-dev and oauth21 profiles the standard OIDC claims are gated by the granted scope (OIDC Core §5.4): email / email_verified require the email scope and preferred_username requires the profile scope. The granted scope is carried on the access token as the scope claim (RFC 9068 §2.2.3). NanoIDP-specific claims (roles, groups, tenant, identity_class, attributes) have no standard scope and are always returned. entitlements and source_acl are deliberately outside that list; which facts reach which surface, and why, is in What each surface says about a user.

This is a distinct mechanism from per-client scope enforcement (see the configuration reference), issue #186: scope gating decides which claims an already-granted scope unlocks at /userinfo; enforcement decides whether a client may be granted that scope at all, at /authorize and /token. A scope that fails enforcement is rejected outright (invalid_scope) long before this gating would ever see it.

Requesting claims in the ID Token (claims parameter)

If your client specifically needs a claim inside the ID Token, use the OpenID Connect claims request parameter at /authorize (OIDC Core §5.5)

  • the standards-aligned way to ask for it. Discovery advertises claims_parameter_supported: true.
GET /authorize?response_type=code&client_id=demo-client
  &redirect_uri=http://localhost:3000/callback&scope=openid
  &claims={"id_token":{"email":null,"email_verified":null}}

(URL-encode the claims value in a real request.) After the code exchange, the ID Token then carries the requested claims:

{
  "iss": "http://localhost:8000",
  "sub": "admin",
  "aud": "demo-client",
  "iat": 1704100000,
  "exp": 1704103600,
  "email": "admin@example.org",
  "email_verified": true
}

The userinfo member (e.g. {"userinfo":{"email":null}}) works the same way for /userinfo and composes with the scope gating above, so a client can pull a specific claim even under a stricter profile that would otherwise gate it out. Claims are resolved from the user and added only when available (voluntary form, §5.5.1); protocol claims are never overwritten and unknown names are skipped. The claims parameter is accepted on the authorization code grant, carried through the refresh_token grant (below), and mirrored by the MCP generate_token tool (id_token_claims / userinfo_claims).

The requested claim names are persisted in the refresh token (like the granted scope and auth_time), so a refreshed ID Token keeps carrying the requested claims and /userinfo keeps honouring the userinfo member for the refreshed access token (OIDC Core §12.2). The nonce is the deliberate exception: it binds the original authentication request and is never re-issued on refresh. Note that a claims request binds to the original authorization and is orthogonal to scope (§5.5), so it is not shed by narrowing the scope on refresh: under a stricter profile, a claim requested via the userinfo member keeps being returned by /userinfo even after the client drops the scope that would otherwise gate it. To shed a claims request, start a new authorization.

All tokens are signed with RS256; verify them against the JWKS at /.well-known/jwks.json (see Endpoints).