Tokens and claims
Audiences and the aud claim
NanoIDP follows the OpenID Connect / OAuth specs for the aud claim:
- ID Token:
audis the requesting client'sclient_id(OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configureadditional_audienceson a client to append extra audiences; if this produces more than one distinct audience value,audis emitted as an array and NanoIDP also emits anazpclaim equal to theclient_idso you can exercise authorized-party validation. - Access Token:
audis the resource audience fromoauth.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:
expis the lifetime in minutes, 1 to 1440, of the access token and of any ID token issued with it, in place ofoauth.token_expiry_minutes. It does not affect refresh tokens (oauth.refresh_token_expiry_minutes). It is not the JWTexpclaim: the claim is computed from it.extrais 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 withinvalid_requestand 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,usernameand the OIDC standard claims such asemailorname). Soextra={"exp": ...}is refused whereexp=15is a lifetime. A token for a user with other roles is obtained by creating that user (a runtime user, or MCPcreate_user), not throughextra.
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": "..."
}
audis theresourcewhen 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
extraparameter 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. /userinfoanswers such a token with{"sub": ...}alone, and/introspectnames nousername, nor theopenidscope it reports for a user token issued without one: a token whosesubequals itsclient_idis 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_userofusers.yaml, with that user's roles;default_useris 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).