Step-Up Authentication (Sudo Mode)
StepUpAuthenticationAspect requires that a request's credential was proven recently — and optionally by a specific method — before a sensitive action is allowed to run. It's Canvas's equivalent of GitHub's "sudo mode": being logged in only proves who you are, not when you last proved it.
The Problem
A session or JWT that's hours old is still "valid" right up until it expires — fine for browsing, but not for destructive or high-value actions like deleting an account, changing an email, or viewing billing details. Anyone who gets hold of that credential for the rest of its lifetime can perform those actions too.
StepUpAuthenticationAspect closes that gap by checking, in addition to normal authentication, how long ago the credential was actually proven — and, optionally, whether it was proven by a strong enough method. If either check fails, the action is denied even though the request is otherwise authenticated.
Quick Start
Stack it on top of whatever already authenticates the request, on just the sensitive method:
<?php
namespace App\Controllers;
use Quellabs\Canvas\Annotations\Route;
use Quellabs\Canvas\Annotations\InterceptWith;
use Quellabs\Canvas\Security\StepUpAuthenticationAspect;
class AccountController extends App\Controllers\AuthenticatedController {
/**
* @Route("/account/delete", methods={"POST"})
* @InterceptWith(StepUpAuthenticationAspect::class, maxAge=300, requiredMethods={"pwd"})
*/
public function deleteAccount(): Response {
// Only reached if the credential was proven within the last 5 minutes via 'pwd'
}
}
AuthenticatedController (scaffolded by canvas-authorization's install:auth) already applies session and account-eligibility checks to every action. This one extra annotation adds a stricter, method-specific gate on top for a single sensitive action — sudo-mode is deliberately opt-in per action, not bundled by default.
Auth-Mechanism Agnostic
StepUpAuthenticationAspect doesn't know or care whether the underlying authenticator is JWT- or session-based. It only reads two request attributes that an earlier @InterceptWith aspect in the chain is expected to set:
| Attribute | Type | Meaning |
|---|---|---|
auth_time |
int|float |
Unix timestamp of when the credential was last proven |
auth_methods |
string[] |
Methods used to obtain the credential (RFC 8176 names recommended, e.g. pwd, hwk, otp; empty when unknown) |
Two authenticators in the Canvas ecosystem currently produce both attributes:
- JwtAuthenticationAspect (Canvas core) — sets
auth_timefrom the token'siatclaim andauth_methodsfrom itsamrclaim, both optional per their respective RFCs. - SessionAuthenticationAspect (
quellabs/canvas-authorization) — sets both directly from theauth_time/auth_methodssession keys, whichcanvas-authorization's scaffolded login writes on successful credential verification. See the Authentication page.
StepUpAuthenticationAspect itself has no idea which of these ran — or whether a third, custom authenticator did.
Configuration
StepUpAuthenticationAspect has no config file — like RateLimitAspect, it's configured entirely through annotation parameters:
| Parameter | Type | Default | Meaning |
|---|---|---|---|
maxAge |
int |
300 |
Required freshness window in seconds. Must be > 0. |
requiredMethods |
string[] |
[] |
When non-empty, at least one must be present in auth_methods. |
throwOnFailure |
bool |
false |
Throw StepUpAuthenticationException instead of writing to request attributes. |
Use two instances for a two-tier policy — a broader threshold for lower-risk actions, a tighter one for higher-risk actions:
// Viewing billing history: logged in within the last 2 hours is enough
/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=7200) */
// Deleting the account: must have logged in within the last 5 minutes
/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=300) */
Failure Modes
| Attribute mode (default) | Exception mode (throwOnFailure=true) |
|
|---|---|---|
| On failure | Sets recent_auth_error on request attributes; controller still executes |
Throws StepUpAuthenticationException; controller never executes |
| Error response | Controller decides format and status code | Handled by your registered ErrorHandlerInterface |
| Best for | Rendering a "confirm your password" form inline | APIs returning a uniform 403 for a client to react to |
Like every other authentication aspect in Canvas, StepUpAuthenticationAspect never builds a Response itself — not even a redirect. There is no automatic "resume the original action" flow either: how you re-prompt for sudo (a confirm-password modal, a redirect to a form, a 403 an SPA reacts to) is specific to the action, not something the framework assumes.
Exception Mode
StepUpAuthenticationException (HTTP 403) carries enough context for a handler to build a machine-readable "step-up required" response:
<?php
namespace App\Errors;
use Quellabs\Canvas\Error\ErrorHandlerInterface;
use Quellabs\Canvas\Exceptions\StepUpAuthenticationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class StepUpErrorHandler implements ErrorHandlerInterface {
public static function supports(\Throwable $e): bool {
return $e instanceof StepUpAuthenticationException;
}
public function handle(\Throwable $e, Request $request): Response {
return new JsonResponse([
'error' => 'step_up_required',
'max_age' => $e->getMaxAge(),
'required_methods' => $e->getRequiredMethods(),
'presented_methods'=> $e->getPresentedMethods(),
'retry_method' => $e->getRequestMethod(),
'retry_path' => $e->getRequestPath(),
], 403);
}
}
The client's "resume" is simply retrying the identical request — retry_method/retry_path — once it satisfies max_age and required_methods.
Requiring a Specific Method
A stolen remember-me cookie shouldn't count as "recent" proof of a credential. Canvas has no built-in remember-me concept, but the same risk exists if your application reissues credentials on silent refresh: a refreshed token or session can look "fresh" by auth_time alone even though the user hasn't entered a credential in days.
requiredMethods is how you close that gap. Tag credentials issued via a weak flow with a method your issuer controls (e.g. refresh), and credentials issued via an interactive login with a strong one (e.g. pwd, hwk, mfa), then require the strong one for sensitive actions:
/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=300, requiredMethods={"pwd"}) */
A token or session with auth_methods = ['refresh'] fails this check regardless of how recent auth_time is; one with auth_methods = ['pwd'] (or ['pwd', 'hwk']) passes. There is no default value for requiredMethods — Canvas doesn't issue or refresh credentials itself, so it has no basis for guessing which of your application's methods are "weak." That decision, and tagging amr (JWT) or auth_methods (session) correctly at issuance time, is entirely on your login/token-issuing code.
Design Choices
- Weak-method exclusion is opt-in, not automatic. Canvas has no built-in opinion on what counts as a weak authentication method. If you don't set
requiredMethods(or don't tagauth_methodsat issuance), a credential refreshed indefinitely without a real login will always read as "recently authenticated" — that's a decision left to your application, not a gap in the check. - Freshness is tracked per credential, not per method.
auth_methodsis a flat list with no per-method timing — the singleauth_timeapplies to the whole credential, not to each method individually. - No resume-after-reauth flow, by design. Canvas is largely stateless — even session-based auth here carries no server-side "pending action" concept — so the reauth UX is deliberately left to the client, rather than the framework guessing what "resume" should mean for a given action.
- Trust is delegated to the issuer, not re-verified.
auth_timeandauth_methodsare only as trustworthy as whatever code setsiat/amrwhen issuing a JWT, orauth_time/auth_methodswhen writing a session at login. Canvas checks freshness and method membership, not whether apwdclaim actually followed a real password check — that's the issuer's responsibility. - Aspect ordering is the developer's responsibility. If
StepUpAuthenticationAspectis placed before the authenticator in@InterceptWith(or no authenticator runs at all),auth_timewill be absent and the check simply fails closed — safe, but the failure reason won't be obvious without checking aspect order. Canvas doesn't infer or enforce a required order between aspects.
Best Practices
- Reserve it for genuinely sensitive actions: account deletion, email/password changes, payment method changes, viewing sensitive financial or personal data — not ordinary page views.
- Place it after the base authenticator:
@InterceptWithannotations execute in declaration order, soStepUpAuthenticationAspectmust come after whatever setsauth_time/auth_methods. - Set
requiredMethodsonce you have a weaker issuance path: if your application ever adds a refresh or remember-me flow, tag it distinctly and require the strong method on sensitive actions from day one. - Prefer exception mode for APIs: a uniform
StepUpAuthenticationExceptionhandler keeps step-up logic out of every controller, the same waythrow_on_failureworks forJwtAuthenticationAspect. - Use two tiers sparingly: most applications need only one
maxAgethreshold for their handful of sensitive actions; reserve a second, shorter tier for the truly destructive ones.