Skip to content

JWT claims

The __session JWT minted by POST /v1/client/sessions/{sid}/tokens is short-lived (default 60s) and carries the active user / session / org / MFA state for tenant backends to consume. This page is the canonical reference for every claim. For how the JWT is fetched and rotated, see Sessions and tokens.

ClaimTypeMeaning
issstringThe FAPI URL (e.g. https://wise-otter-x4f.authn.sh).
substringThe user ID (user_…).
sidstringThe session ID (sess_…).
azpstringThe Origin that minted the token, when the request had one.
iat / nbf / expnumberStandard JWT timestamps. Default lifetime: 60s.
vnumberToken format version. Currently 2.
ClaimTypeMeaning
stsstringactive or pending. pending while a step-up flow (device trust, fresh-MFA) is still in progress.
fva[number, number][seconds_since_first_factor, seconds_since_second_factor]. The second factor is -1 when the user has no MFA enrolled.

fva lets backends gate sensitive endpoints on a fresh second-factor proof — e.g. require fva[1] >= 0 && fva[1] < 300 to demand the user re-MFA’d in the last 5 minutes.

When the session has an active organization set, the JWT gains an org claim:

{
"org": {
"id": "org_01K…",
"slug": "acme",
"role": "org:admin",
"permissions": [
"org:sys_domains:manage",
"org:sys_memberships:manage"
]
}
}

org is absent when no organization is active. Backends must treat its absence as “no org context” — never assume a default. See Organizations for how the active-org pivot works.

When the user has at least one second factor enrolled and the session was minted after a successful second-factor proof:

ClaimTypeMeaning
tfebooleantrue when User.two_factor_enabled was true at session-creation time.
mfastring[]The second-factor strategies the user has enrolled at session-creation time. v0.3 surface: ["totp"], ["backup_code"], ["totp", "backup_code"]. v0.4 adds "phone_code".

tfe is the cheapest gate — backends needing “this user has MFA on” for compliance can read it directly without re-fetching the user.

When the user has at least one verified phone and / or has reserved a phone for SMS MFA:

ClaimTypeMeaning
pnvbooleanPhone-number-verified. true when the user has at least one PhoneNumber row with verified: true.
dsfstring | nullDefault second factor. The strategy the SDK preselects on needs_second_factor: one of "phone_code", "totp", or null (the user has no preference set or no second factor enrolled). Driven by PhoneNumber.default_second_factor.

The mfa claim grows to include "phone_code" when the user has any phone with reserved_for_second_factor: true:

{
"tfe": true,
"mfa": ["totp", "phone_code"],
"pnv": true,
"dsf": "phone_code"
}

VerifiedClaims exposes typed accessors for every claim:

use AuthnSh\SdkPhp\VerifiedClaims;
$claims = $authn->jwt()->verify($token);
if ($claims->hasVerifiedPhoneNumber()) {
// pnv: true
}
if ($claims->getDefaultSecondFactor() === 'phone_code') {
// dsf: "phone_code"
}
if ($claims->hasMfa('phone_code')) {
// mfa array contains "phone_code"
}

Full VerifiedClaims accessor list:

MethodReads claim
getUserId()sub
getSessionId()sid
getOrganizationId()org.id
hasPermission(string)org.permissions[]
hasMfa(string)mfa[]
hasVerifiedPhoneNumber()pnv
getDefaultSecondFactor()dsf
getFirstFactorAge()fva[0]
getSecondFactorAge()fva[1]

The Blade directive surfaces the same checks:

@authnHasVerifiedPhoneNumber
<p>Your phone is verified.</p>
@endauthnHasVerifiedPhoneNumber
@authnHasConnectedAccount('google')
<p>You're signed in with Google.</p>
@endauthnHasConnectedAccount

@authnHasConnectedAccount reads the User.external_accounts[] snapshot the SDK caches on session creation; pass the OauthProvider.provider_key you want to test for.

The middleware:

Route::get('/dashboard', fn () => view('dashboard'))
->middleware('requires.connected_account:google');

RequiresConnectedAccount rejects with 403 when the user has no matching ExternalAccount. Useful for views that depend on a particular provider’s data being available (e.g. a “Sync from GitHub” page).

Authn.session.lastActiveToken carries the decoded claims; the typed shape mirrors the table above:

import { Authn } from '@authn-sh/sdk-js';
const claims = Authn.session?.lastActiveToken?.claims;
if (claims?.pnv) {
// phone verified
}
if (claims?.dsf === 'phone_code') {
// user prefers SMS MFA
}

For backend verification (Express, FastAPI, Go, …), use a standards-compliant JWT library against the env’s JWKS — see Verify JWTs in a backend. The claim names above are the contract; nothing magical.

A few things the SDK reads off User directly rather than from the JWT:

  • User.external_accounts[] — the full ExternalAccount list. Available via useUser().user.externalAccounts or GET /v1/me/external-accounts.
  • User.phone_numbers[] — full list. Available via usePhoneNumbers() or GET /v1/me/phone-numbers.
  • User.email_addresses[] — full list (v0.2).

These would bloat the JWT past comfortable size for long-lived sessions with many connections; the SDK fetches them on demand and caches them on the client snapshot.

The JWT format version is on the v claim. v0.1 / v0.2 ship v: 1; v0.3 introduced MFA claims and bumped to v: 2. v0.4 adds pnv / dsf without a version bump — adding optional claims is backwards-compatible for backends that ignore unknown keys (the standard JWT-handling library posture).

If a backend rejects unknown claims, pin to v: 2 consumers; most don’t.