Sessions & step-up
The IdP issues tokens, but the session behind them is server-side and revocable — so an admin can cut
off access immediately, and the PDP can demand a stronger proof of identity before a sensitive action.
Session code lives in src/Domain/Identity/Session/; assurance in src/Domain/Identity/Assurance/.
Why server-side sessions
A bare JWT is valid until it expires — you cannot revoke it. Binding tokens to a server-side session record
(via a sid) means revocation is immediate and auditable, idle/absolute timeouts are enforced centrally,
and every session check is fail-closed: if the session can’t be confirmed valid, access is denied.
Revoking sessions
Through the Admin API:
# revoke one session
curl -X POST https://iam.example.com/api/iam/v1/sessions/{session}/revoke -H "Authorization: Bearer $ADMIN_TOKEN"
# revoke every session for a user
curl -X POST https://iam.example.com/api/iam/v1/users/{user}/sessions/revoke-all -H "Authorization: Bearer $ADMIN_TOKEN"
# inspect
curl https://iam.example.com/api/iam/v1/sessions -H "Authorization: Bearer $ADMIN_TOKEN"
curl https://iam.example.com/api/iam/v1/sessions/{session} -H "Authorization: Bearer $ADMIN_TOKEN"
Every revocation is recorded in the audit chain.
Step-up to AAL2
A permission can require a higher assurance level. When the session’s
currentAal is below what a policy needs, the PDP returns:
$decision->allowed; // false
$decision->requiresStepUp; // true
$decision->requiredAal; // 'aal2'
The caller sends the user through a step-up challenge (passkey / MFA), the session’s AAL is elevated, and
the action is retried:
Step-up challenges are tracked in iam_step_up_challenges; passkeys (the laravel/passkeys suggest
dependency) satisfy AAL2. The native step-up provider caps at AAL2 — a requiredAal of aal3 is rejected
up front, since a passkey/TOTP can’t attest a hardware authenticator (that needs a dedicated adapter).
Enforcement on the Admin API. A requires_step_up admin route doesn’t just advise — the iam.can
middleware turns an insufficient AAL into a 403 step-up-required
problem (carrying required_aal), distinct from a plain forbidden denial. The actor’s AAL is read from the
access token: after a step-up the session’s AAL is elevated and, on the next token refresh, the freshly
minted access token carries the aal claim (bound to the session sid) — so the same operator now passes the
gate. The freshness window (IAM_SESSION_STEPUP_FRESHNESS, default 15m) is applied both by the live PDP /
assurance evaluation and when the access token’s aal is stamped: an elevation past its window is minted as
aal1, so a refresh can’t re-stamp a fresh aal2 from a stale step-up. (An aal2 from the initial login —
no recorded step_up_at — is not a step-up elevation and doesn’t expire.)
Elevated assurance is bound to the session and subject to the same timeouts. A long-idle session can drop
back below AAL2, so design sensitive flows to re-check the decision rather than caching “this user stepped
up once”.
Two kinds of 2FA — don’t confuse them
- Per-action step-up (this page, the PDP): a permission can demand AAL2 via
requires_step_up; the PDP
returnsrequiresStepUpuntil the caller has stepped up. This protects sensitive actions in any app,
decided at authorization time. - Mandatory console login 2FA (the host console): a security posture for the admin console operators.
The deployable host (laravel-iam-console) offers
IAM_CONSOLE_2FA=true(operators may enrol TOTP) andIAM_CONSOLE_2FA_REQUIRED=true(every operator is
forced to enrol before using the console). That’s login-time enforcement for the panel, separate from
the PDP step-up above.
Session lifecycle, timeouts & retention
A session is active while it is not revoked, not past its idle window, and not past its absolute
ceiling. The timeouts are configurable (iam.authentication.session.*), env-driven on the host:
| Setting | Env | Default | Meaning |
|---|---|---|---|
| idle timeout | IAM_SESSION_IDLE_TIMEOUT |
1800 (30m) |
Re-auth after this much inactivity. |
| absolute timeout | IAM_SESSION_ABSOLUTE_TIMEOUT |
43200 (12h) |
Hard ceiling — never extended. |
| step-up window | IAM_SESSION_STEPUP_WINDOW |
300 (5m) |
How long a step-up challenge stays valid to complete. |
| step-up freshness | IAM_SESSION_STEPUP_FRESHNESS |
900 (15m) |
How long a completed step-up keeps satisfying a requires_step_up route before it must be repeated. |
| concurrent limit | IAM_SESSION_CONCURRENT_LIMIT |
unlimited | Max concurrent sessions per subject. |
| retention | IAM_SESSION_RETENTION_DAYS |
90 |
Days before ended/expired rows are pruned. |
States you’ll see (e.g. in the console Sessions grid): active · idle (past the idle window) ·
expired (past the absolute ceiling) · revoked (with a reason: logout, idle, absolute_expired,
device_removed, …).
Keeping the table bounded — iam:prune-sessions
Expiry is evaluated on each request, so a session that idles out is rejected the moment its owner returns —
but a session no one comes back to would otherwise sit in iam_sessions forever with revoked_at = null.
Schedule the prune command daily:
// routes/console.php (host)
Schedule::command('iam:prune-sessions')->daily();
It runs two steps: (1) an expiry sweep — marks idle- and absolute-expired sessions as revoked with the
reason (idle / absolute_expired), so the store reflects reality; (2) retention — hard-deletes rows
revoked longer than IAM_SESSION_RETENTION_DAYS. Override the window per run with --days=.
Next
- Assurance levels (AAL) — NIST 800-63B, formally.
- Ask the PDP — the
requiresStepUppath in context. - OIDC login — where the initial AAL is recorded.
- CLI reference —
iam:prune-sessionsand the other commands.