JWT claim catalog
Every claim Synq emits on access tokens, ID tokens, m2m tokens, and device-flow tokens. The complete shape for each token type.
This is the page to read when implementing a custom verifier or adding scope-based authorization to your code.
Access token (access_token)
Issued by every grant type. Audience is the brand slug.
interface AccessTokenPayload {
// Required claims
sub: string // user id (m2m: client_id)
aud: string // brand slug
iss: string // 'https://synq.id'
scope: string // granted scopes, space-separated
iat: number // issued-at, unix seconds
exp: number // expires-at, unix seconds
// Always present on user-authed tokens
azp: string // client_id that obtained the token
// Present when the user authenticated
auth_time?: number // unix seconds — when user authed
// Present on device-flow access tokens
act?: {
type: 'agent'
client_id: string // the agent app's client_id
}
}Claim meanings
sub— the subject. For user-authed tokens, this is the Synq user id. For m2m tokens (client_credentialsgrant), this is the client_id (no user is involved).aud— the audience. For access tokens, this is the Brand slug. The Brand slug lets the same backend service accept tokens from any App under the same Brand (web + mobile + CLI share the brand audience).scope— space-separated granted scopes. Always includes whatever the App requested ∩ what the user consented to.azp— authorized party. Theclient_idof the App that obtained this token. Useful when one backend serves multiple Apps under the same Brand and needs to know which App is calling.auth_time— when the user actually authenticated. Useful for “step-up” authorization (require re-auth for sensitive actions ifauth_timeis older than N minutes).act— actor. Present only on device-flow tokens. Lets your backend differentiate “user signed in directly” from “agent signed in on behalf of user via device flow”, and apply tighter scope policies for the latter if desired.
Verifying
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://synq.id/.well-known/jwks.json'))
const { payload } = await jwtVerify<AccessTokenPayload>(token, JWKS, {
issuer: 'https://synq.id',
audience: ['marketplace', 'admin-tools'], // accept multiple
clockTolerance: '5 seconds',
})
if (!payload.scope?.split(' ').includes('wallets:read')) {
throw new Error('forbidden')
}ID token (id_token)
Issued alongside the access token when openid is in the requested
scope. Audience is your client_id.
interface IdTokenPayload {
// Required
sub: string // user id (same as access_token.sub)
aud: string // your client_id (different from access aud)
iss: string // 'https://synq.id'
iat: number
exp: number
auth_time: number
// Returned to round-trip nonce — VERIFY THIS
nonce?: string
// 'email' scope
email?: string
email_verified?: boolean
// 'profile' scope
given_name?: string
family_name?: string
preferred_username?: string
picture?: string // URL
locale?: string // BCP-47, e.g. 'en-US'
// Brand-specific claims (only when an explicit brand scope grants them)
// Example: a brand-defined 'wallets:profile' scope might add:
// wallets?: Array<{ address: string, blockchain: string, isPrimary: boolean }>
}Verifying nonce
The ID token’s nonce claim must match the nonce you generated
in step 2 of the Quickstart:
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://synq.id/.well-known/jwks.json'))
const { payload } = await jwtVerify<IdTokenPayload>(idToken, JWKS, {
issuer: 'https://synq.id',
audience: process.env.SYNQ_CLIENT_ID, // your client_id, NOT brand slug
})
if (payload.nonce !== savedNonce) {
throw new Error('nonce mismatch — possible replay')
}Differences from access_token
access_token | id_token | |
|---|---|---|
aud | brand slug | your client_id |
azp | yes | no |
nonce | no | yes (when sent) |
| Profile claims | no | yes (when scopes granted) |
| Usage | authorize backend calls | render the user; capture email |
| Use case | repeated, every request | one-shot, at sign-in |
m2m access token (client_credentials grant)
Same shape as access token, with:
sub === client_id(no end user)- No
auth_time, noact - No
id_tokenissued (m2m clients don’t get profile info) - No
refresh_tokenissued
The scope claim contains the scopes the m2m client requested and
that are allowed on its App configuration.
interface M2mAccessTokenPayload {
sub: string // client_id
aud: string // brand slug
iss: string // 'https://synq.id'
azp: string // client_id (same as sub for m2m)
scope: string
iat: number
exp: number
}Distinguishing m2m from user tokens
A common pattern: your backend needs to know if a token is m2m
(internal service) or user (front-end app). Check whether sub
equals azp — m2m tokens always have them equal; user tokens
always have sub = user_id and azp = client_id.
function isM2mToken(payload: AccessTokenPayload) {
return payload.sub === payload.azp
}Or check auth_time — m2m never has it.
Device-flow access token
Same shape as access token, with act present:
{
sub: 'user_abc',
aud: 'marketplace',
azp: 'cli-app',
scope: 'openid profile',
iat: 1717000000,
exp: 1717003600,
auth_time: 1717000000,
act: {
type: 'agent',
client_id: 'cli-app'
}
}Use act to apply tighter scope policies for agent-issued tokens.
For example, your backend might reject act tokens from writing
sensitive data unless the user re-authenticated within the last 5
minutes (Date.now() - auth_time*1000 < 5*60*1000).
JWT header (for completeness)
Every Synq JWT has this header:
{
alg: 'RS256',
typ: 'JWT',
kid: '<key id — matches one in JWKS>'
}Reject tokens whose alg is not RS256. Synq does not issue HS256
or RS512 tokens.
Custom claims via brand scopes
A brand can register custom scopes (see
Scopes and consent). Granted brand
scopes appear in the access token’s scope claim. They do not
add new top-level claims — your backend reads scope and decides
what each one means.
If you need additional data shipped in the token itself (e.g. a wallet address as a top-level claim), open a support request — that’s configurable on the brand and not exposed in the public API yet.
Mental model summary
sub= subject. User id (or client_id for m2m).aud= audience. Brand slug on access tokens; your client_id on ID tokens.azp= authorized party = client_id that obtained the token.scope= granted scopes, space-separated.act= present on device-flow tokens; identifies agents.nonce= present on ID tokens; verify against what you sent.- Always RS256, always
kidreferences JWKS.