OpenID Connect (ID Tokens & UserInfo)
Signet supports OpenID Connect 1.0 on top of the Authorization Code Flow. When you include openid in your requested scope, Signet issues an ID token alongside the access token and makes /oauth/userinfo available.
Device Flow does not currently issue ID tokens. For OIDC you need the Authorization Code Flow.
ID Token vs. Access Token
| Question | ID Token | Access Token |
|---|---|---|
| Who is it about? | The end user (identity) | An authorization to call an API |
| Who is it for? | Your client application (aud=client_id) |
The resource server(s) the token is bound to (aud=per-request resource or JWT_AUDIENCE) |
Sent to APIs as Authorization: Bearer? |
No — never | Yes |
Validate aud? |
Yes — must equal your client_id |
Yes — must equal the resource server’s identifier (see JWT Verification §Audience Binding) |
Validate nonce? |
Yes — must match what you sent | N/A |
| Contains PII? | Yes (email, name, picture, depending on scope) | No |
Rule of thumb: only your own client app should ever parse the ID token. Pass it to another service and you’re leaking the user’s identity to a party that isn’t the audience.
Request an ID Token
In the Authorization Code Flow, include openid in scope and a nonce:
GET /oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example/callback
&response_type=code
&scope=openid profile email
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
After the code exchange at /oauth/token, the response includes an id_token:
{
"access_token": "eyJhbG...",
"refresh_token": "def502...",
"id_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
ID Token Claims
Header:
{
"alg": "RS256",
"kid": "abc123...",
"typ": "JWT"
}
Payload (shape depends on the granted scopes):
| Claim | Always | When added | Meaning |
|---|---|---|---|
iss |
✓ | Issuer URL — must equal the discovery document’s issuer |
|
sub |
✓ | Stable user identifier (UUID) | |
aud |
✓ | Your client_id — must match for the token to be valid |
|
exp |
✓ | Expiration (Unix time) | |
iat |
✓ | Issued-at (Unix time) | |
auth_time |
✓ | When the user authenticated (Unix time) | |
jti |
✓ | Unique token ID | |
nonce |
— | If you sent nonce in the authorization request |
Must equal the value you sent — prevents replay |
at_hash |
— | When an access token is co-issued | First half of SHA-256(access_token), base64url-encoded |
name |
— | scope includes profile |
Full display name |
preferred_username |
— | scope includes profile |
Username for display (e.g. alice) |
picture |
— | scope includes profile and user has avatar |
Avatar URL |
updated_at |
— | scope includes profile |
Profile last-updated (Unix time) |
email |
— | scope includes email |
Primary email |
email_verified |
— | scope includes email |
true if the email has been verified (e.g. via OAuth provider) |
Verifying the ID Token
Use the same JWKS mechanics as access tokens (JWT Verification) but with tighter rules:
- Signature — verify against the JWKS key matching the
kidheader.
iss— must equal theissuerfrom the discovery document (BASE_URLin canonical form: lowercased scheme and host, no trailing slash).
aud— must equal yourclient_id. Ifaudis an array, it must contain yourclient_idand no untrusted values.
exp— must be in the future (small clock-skew tolerance, e.g. 30s).
iat— should be reasonably recent.
nonce— must equal thenonceyou sent in the authorization request.
auth_time— if you requestedmax_age, enforce it.
at_hash(optional, recommended) — verify it matches the access token you also received.
Go (golang-jwt + keyfunc)
import (
"strings"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
jwksURL := "https://your-signet/.well-known/jwks.json"
k, _ := keyfunc.NewDefault([]string{jwksURL})
token, err := jwt.Parse(idTokenString, k.Keyfunc,
jwt.WithIssuer("https://your-signet"),
jwt.WithAudience(clientID), // enforces aud
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{"RS256", "ES256"}),
)
if err != nil {
return fmt.Errorf("invalid id_token: %w", err)
}
claims := token.Claims.(jwt.MapClaims)
nonce, ok := claims["nonce"].(string)
if !ok || nonce != expectedNonce {
return fmt.Errorf("nonce mismatch")
}
Python (PyJWT)
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient(f"{SIGNET_URL}/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256", "ES256"],
issuer=SIGNET_URL,
audience=CLIENT_ID, # enforces aud
options={"require": ["exp", "iss", "sub", "aud"]},
)
if claims.get("nonce") != expected_nonce:
raise ValueError("nonce mismatch")
Node.js (jose)
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL(`${SIGNET_URL}/.well-known/jwks.json`));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: SIGNET_URL,
audience: CLIENT_ID, // enforces aud
algorithms: ["RS256", "ES256"],
});
if (payload.nonce !== expectedNonce) throw new Error("nonce mismatch");
UserInfo Endpoint
For scope-gated user claims at request time, call /oauth/userinfo with the access token (not the ID token):
curl -H "Authorization: Bearer ACCESS_TOKEN" https://your-signet/oauth/userinfo
Response (shape depends on granted scopes):
{
"sub": "user-uuid",
"iss": "https://your-signet",
"name": "Alice Example",
"preferred_username": "alice",
"picture": "https://...",
"updated_at": 1700000000,
"email": "alice@example.com",
"email_verified": true
}
- Always includes
subandiss
profilescope gatesname,preferred_username,picture,updated_at
emailscope gatesemail,email_verified
On an invalid/expired token, UserInfo returns 401 Unauthorized with WWW-Authenticate: Bearer error="invalid_token", resource_metadata="<issuer>/.well-known/oauth-protected-resource". The resource_metadata pointer (RFC 9728 §5.1) is present unless PROTECTED_RESOURCE_METADATA_ENABLED=false; follow it to discover the authorization server.
When to hit UserInfo vs. read ID token claims? The ID token is a one-shot identity proof valid at login. For up-to-date profile data (e.g., the user changed their avatar), hit UserInfo with the current access token.
Discovery
Your OIDC library should auto-configure from:
https://your-signet/.well-known/openid-configuration
See Getting Started for the full document shape.
Signet also publishes a parallel RFC 8414 document at
/.well-known/oauth-authorization-serverfor non-OIDC OAuth 2.1 / MCP clients. The two documents are kept in sync for fields they share — pick whichever your library expects. When the operator enables Client ID Metadata Documents (CIMD_ENABLED=true), the RFC 8414 document additionally advertisesclient_id_metadata_document_supported: true— see the Authorization Code Flow guide for what that enables.
Common Pitfalls
- Sending the ID token as a Bearer to an API. Don’t. Use the access token.
- Skipping
audvalidation. Without it, an ID token issued for another client could be accepted as yours.
- Skipping
noncevalidation. Always send and validatenonce. The spec marks it OPTIONAL for the auth-code flow, but omitting it forfeits replay protection and is strongly discouraged.
- Parsing the ID token without verifying the signature. Never — the JWT is not authenticated until you verify it.
- Validating
aud=client_idon access tokens. Access tokens carryaud=<resource server identifier>(the RFC 8707resourcevalue orJWT_AUDIENCE), never yourclient_id. Useaud=client_idonly when validating ID tokens.