You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Handle step-up-required (401) by re-running the passkey ceremony on sensitive operations #75
SpringUserFramework is adding an opt-in WebAuthn step-up primitive (library issue: devondragon/SpringUserFramework#335, finding SUF-02). When user.security.stepUp.enabled=true, sensitive operations on passwordless (passkey-only) accounts require a WebAuthn assertion performed within a short TTL (default 300s):
POST /user/setPassword
passkey delete / rename (WebAuthnManagementAPI)
The library ships the server side. This app needs the client handling and the E2E coverage, and serves as the reference implementation.
Scope changed 2026-08-18. This issue originally specified a bespoke ceremony against POST /user/stepup/options and POST /user/stepup/verify. Those endpoints are not being built. The library now expresses step-up as a freshness requirement on the WebAuthn factor that Spring Security already issues, so the step-up ceremony is the ordinary passkey login ceremony, re-run while the user is still logged in. Re-running it refreshes the factor's issuedAt and preserves the session's other authorities.
What the demo app needs
Handle the step-up-required response. Sensitive endpoints return 401 with code step-up-required when no sufficiently recent assertion exists. On that response, run the existing passkey ceremony and retry the original call.
Reuse the existing ceremony.src/main/resources/static/js/user/webauthn-authenticate.js already exports authenticateWithPasskey(), which does /webauthn/authenticate/options -> navigator.credentials.get() -> /login/webauthn. No new ceremony code, and no new endpoints to call.
UI wiring on the affected flows: set-password on a passwordless account, and passkey delete/rename in webauthn-manage.js. Prompt the user before triggering the authenticator so the browser dialog is not a surprise.
Playwright E2E for the enabled path (user.security.stepUp.enabled=true), following the virtual-authenticator setup already used in playwright/tests/mfa/mfa-flow.spec.ts: passwordless account, attempt a sensitive op, ceremony, success; plus the negative path (stale or absent factor, no ceremony, 401).
Why the E2E matters more than usual
Step-up is a full re-login, so the session-fixation strategy runs, the framework's documented duplicate InteractiveAuthenticationSuccessEvent fires, and the /login/webauthn JSON response lands in the middle of another flow. That was checked by reading the code but not in a browser; this issue is where it actually gets verified.
Notes
Default is stepUp.enabled=false, so existing demo flows are unaffected until the flag is turned on.
changed the title [-]Add WebAuthn step-up ceremony (JS + UI + E2E) for passwordless sensitive operations[/-][+]Handle step-up-required (401) by re-running the passkey ceremony on sensitive operations[/+]on Aug 19, 2026
Library side has landed — updated scope, corrections, and acceptance criteria
devondragon/SpringUserFramework#365 and #367 are merged to main (unreleased, 5.3.3-SNAPSHOT). This issue is no longer blocked. Several details above are now stale, and the scope grew.
Corrections to the description above
TTL default is 120, not 300. Key is user.security.stepUp.ttlSeconds.
factors is a list, not a single factor: user.security.stepUp.factors, default [WEBAUTHN], evaluated any-of.
Passkey enrollment is now gated too, which the description does not mention at all. See below.
New: enrollment is gated, and it fails differently
POST /webauthn/register now requires an authentication factor issued within user.security.stepUp.enrollmentTtlSeconds (default 600), whenever step-up is enabled. Without this the whole feature protected nothing: an attacker with a stolen session cookie could enroll their own passkey, assert with it to mint a fresh FACTOR_WEBAUTHN, and satisfy every other gate.
This fails as a plain 403 from the filter chain, not a 401 with step-up-required. It is an authorizeHttpRequests rule, so it never reaches the controller advice that produces the JSON error body. Client handling is therefore different from the other three operations, and the UI needs a message that makes sense for it ("log in again before adding a passkey"), not the generic step-up retry.
Any factor satisfies enrollment, deliberately not the configured factors list, because with the default [WEBAUTHN] requiring a configured factor would demand a passkey in order to register a first passkey.
Two interactions that will bite the E2E setup
1. The mfa profile unprotects /webauthn/register, but the gate still applies.application-mfa.yml:25 lists /webauthn/register in unprotectedURIs, and mfa-flow.spec.ts relies on that to enroll a first passkey while only partially authenticated. The library registers the enrollment gate before the unprotectedURIspermitAll() rule (WebSecurityConfig.java:197 vs :206), so with step-up enabled the gate wins regardless. In practice that flow still passes, because a partially-authenticated MFA session carries a fresh FACTOR_PASSWORD from the password step. But if enrollmentTtlSeconds is ever set shorter than the time the test spends between login and enrollment, it will start failing in a way that looks unrelated.
2. Dev login stamps no authentication factor, by design.user.dev.auto-login-enabled impersonation proves nothing about presence, so authWithoutPassword(user, DEV_LOGIN) deliberately adds no factor. Any test that authenticates via /dev/login-as/{email} and then tries to enroll a passkey will get a 403 while step-up is on.remember-me.spec.ts is the current dev-login user; anything new should use a real login for passkey work. Remember-me sessions carry no factor either, for the same reason.
Login flows now stamp factors that previously did not
To make the enrollment gate satisfiable, the library now stamps a factor on three paths that produced none. Relevant here because it changes what a session carries after these flows:
OIDC login (Keycloak, or Google configured with the openid scope) — OidcAuthorizationCodeAuthenticationProvider stamps nothing upstream, unlike the password and plain-OAuth2 providers. The library adds FACTOR_AUTHORIZATION_CODE when a login produced no factor at all. Worth exercising under docker-keycloak.
Without these, a newly registered user's first session carried no factor and could never enroll a first passkey. The passwordless-registration flow in this app depends on that working.
Social-login accounts no longer get a dead end
hasPassword() is a null check on the password column, and OAuth/OIDC users are created without one, so every social-login account is "passwordless" for this feature. Previously, enabling step-up gave them a permanent 401 on POST /user/setPassword with no configuration that recovered it. Now StepUpService.canSatisfyStepUp distinguishes "has not authenticated recently" from "could never satisfy this", and such accounts fall back to user.security.allowInitialPasswordSetWithoutStepUp as before. Worth an E2E case, since "sign in with Google, then add a password" is an ordinary flow that was broken.
Enrollment notification (#367)
Independent of step-up and on by default: every passkey registration now publishes a PasskeyRegistration audit event and emails the account owner (user.webauthn.notifyOnRegistration, default true). The demo app captures mail already, so this is cheap to assert and it is the detective half of the same threat.
Acceptance criteria
Client handling
A 401 with error code step-up-required on setPassword, passkey delete, or passkey rename triggers the existing authenticateWithPasskey() ceremony and retries the original call.
The user is prompted before the authenticator dialog is triggered, so the browser prompt is not a surprise.
A 403 on POST /webauthn/register surfaces a distinct, accurate message. It is not a step-up-required retry and re-running the passkey ceremony will not fix it if the user has no passkey yet.
No new endpoints are called. The ceremony is /webauthn/authenticate/options → navigator.credentials.get() → /login/webauthn, unchanged.
E2E, step-up enabled
Passwordless account, sensitive operation with no recent assertion → 401, ceremony runs, retry succeeds.
Passwordless account, factor deliberately aged past ttlSeconds → 401 before any mutation occurs.
Passkey delete and rename each covered, not just setPassword.
Enrollment with a stale session → 403, and the UI explains it.
Enrollment shortly after a real login → succeeds.
Social-login (OIDC) account with no passkey → setPassword is governed by allowInitialPasswordSetWithoutStepUp, not a permanent 401.
First-passkey enrollment works for a freshly registered account whose only session came from the verification link or post-registration auto-login.
E2E, step-up disabled (the default)
Every existing passkey, MFA, and registration flow behaves exactly as it does today. This is the regression gate on the whole feature being opt-in.
The reason this issue exists — browser-only verification
These were reasoned about in code review but never observed in a browser. Step-up is a full re-login, so:
Session fixation. The session id changes on re-assertion and the user is not logged out or left with a dead session.
Duplicate InteractiveAuthenticationSuccessEvent. The framework documents that a second event fires; confirm the demo app's listeners (session profile, audit) tolerate it and do not double-count or corrupt state.
Login JSON mid-flow./login/webauthn returns a JSON body in the middle of another operation; confirm it does not navigate away, clobber the page, or leave the retry unfired.
Authorities survive. After step-up, the user retains roles and any session-only authorities. Factor merging is what protects this, and it is enabled by step-up as well as by user.mfa.enabled.
Notification (#367)
Registering a passkey sends the owner an email and records a PasskeyRegistration audit event.
user.webauthn.notifyOnRegistration=false suppresses the email and still records the audit event.
Library reference: acceptance criteria and rationale on devondragon/SpringUserFramework#335; CONFIG.md and MIGRATION.md on main carry the full property documentation and the documented residual risks.
Activity
[-]Add WebAuthn step-up ceremony (JS + UI + E2E) for passwordless sensitive operations[/-][+]Handle step-up-required (401) by re-running the passkey ceremony on sensitive operations[/+]feat: built-in WebAuthn step-up for credential-altering operations (#365
devondragon commentedon Aug 21, 2026
Library side has landed — updated scope, corrections, and acceptance criteria
devondragon/SpringUserFramework#365 and #367 are merged to
main(unreleased,5.3.3-SNAPSHOT). This issue is no longer blocked. Several details above are now stale, and the scope grew.Corrections to the description above
120, not300. Key isuser.security.stepUp.ttlSeconds.factorsis a list, not a singlefactor:user.security.stepUp.factors, default[WEBAUTHN], evaluated any-of.New: enrollment is gated, and it fails differently
POST /webauthn/registernow requires an authentication factor issued withinuser.security.stepUp.enrollmentTtlSeconds(default600), whenever step-up is enabled. Without this the whole feature protected nothing: an attacker with a stolen session cookie could enroll their own passkey, assert with it to mint a freshFACTOR_WEBAUTHN, and satisfy every other gate.This fails as a plain
403from the filter chain, not a401withstep-up-required. It is anauthorizeHttpRequestsrule, so it never reaches the controller advice that produces the JSON error body. Client handling is therefore different from the other three operations, and the UI needs a message that makes sense for it ("log in again before adding a passkey"), not the generic step-up retry.Any factor satisfies enrollment, deliberately not the configured
factorslist, because with the default[WEBAUTHN]requiring a configured factor would demand a passkey in order to register a first passkey.Two interactions that will bite the E2E setup
1. The
mfaprofile unprotects/webauthn/register, but the gate still applies.application-mfa.yml:25lists/webauthn/registerinunprotectedURIs, andmfa-flow.spec.tsrelies on that to enroll a first passkey while only partially authenticated. The library registers the enrollment gate before theunprotectedURIspermitAll()rule (WebSecurityConfig.java:197vs:206), so with step-up enabled the gate wins regardless. In practice that flow still passes, because a partially-authenticated MFA session carries a freshFACTOR_PASSWORDfrom the password step. But ifenrollmentTtlSecondsis ever set shorter than the time the test spends between login and enrollment, it will start failing in a way that looks unrelated.2. Dev login stamps no authentication factor, by design.
user.dev.auto-login-enabledimpersonation proves nothing about presence, soauthWithoutPassword(user, DEV_LOGIN)deliberately adds no factor. Any test that authenticates via/dev/login-as/{email}and then tries to enroll a passkey will get a 403 while step-up is on.remember-me.spec.tsis the current dev-login user; anything new should use a real login for passkey work. Remember-me sessions carry no factor either, for the same reason.Login flows now stamp factors that previously did not
To make the enrollment gate satisfiable, the library now stamps a factor on three paths that produced none. Relevant here because it changes what a session carries after these flows:
openidscope) —OidcAuthorizationCodeAuthenticationProviderstamps nothing upstream, unlike the password and plain-OAuth2 providers. The library addsFACTOR_AUTHORIZATION_CODEwhen a login produced no factor at all. Worth exercising underdocker-keycloak.FACTOR_OTT.FACTOR_PASSWORD.Without these, a newly registered user's first session carried no factor and could never enroll a first passkey. The passwordless-registration flow in this app depends on that working.
Social-login accounts no longer get a dead end
hasPassword()is a null check on the password column, and OAuth/OIDC users are created without one, so every social-login account is "passwordless" for this feature. Previously, enabling step-up gave them a permanent401onPOST /user/setPasswordwith no configuration that recovered it. NowStepUpService.canSatisfyStepUpdistinguishes "has not authenticated recently" from "could never satisfy this", and such accounts fall back touser.security.allowInitialPasswordSetWithoutStepUpas before. Worth an E2E case, since "sign in with Google, then add a password" is an ordinary flow that was broken.Enrollment notification (#367)
Independent of step-up and on by default: every passkey registration now publishes a
PasskeyRegistrationaudit event and emails the account owner (user.webauthn.notifyOnRegistration, defaulttrue). The demo app captures mail already, so this is cheap to assert and it is the detective half of the same threat.Acceptance criteria
Client handling
401with error codestep-up-requiredonsetPassword, passkey delete, or passkey rename triggers the existingauthenticateWithPasskey()ceremony and retries the original call.403onPOST /webauthn/registersurfaces a distinct, accurate message. It is not astep-up-requiredretry and re-running the passkey ceremony will not fix it if the user has no passkey yet./webauthn/authenticate/options→navigator.credentials.get()→/login/webauthn, unchanged.E2E, step-up enabled
401, ceremony runs, retry succeeds.ttlSeconds→401before any mutation occurs.setPassword.403, and the UI explains it.setPasswordis governed byallowInitialPasswordSetWithoutStepUp, not a permanent401.E2E, step-up disabled (the default)
The reason this issue exists — browser-only verification
These were reasoned about in code review but never observed in a browser. Step-up is a full re-login, so:
InteractiveAuthenticationSuccessEvent. The framework documents that a second event fires; confirm the demo app's listeners (session profile, audit) tolerate it and do not double-count or corrupt state./login/webauthnreturns a JSON body in the middle of another operation; confirm it does not navigate away, clobber the page, or leave the retry unfired.user.mfa.enabled.Notification (#367)
PasskeyRegistrationaudit event.user.webauthn.notifyOnRegistration=falsesuppresses the email and still records the audit event.Config for the E2E profile
Library reference: acceptance criteria and rationale on devondragon/SpringUserFramework#335;
CONFIG.mdandMIGRATION.mdonmaincarry the full property documentation and the documented residual risks.fix: apply step-up review findings — enrollment 403, CSRF, mail templ…
test: cover TTL-expiry, stale-session enrollment 403, and verificatio…
test: assert passkey-registration notification email via Mailpit (#75)
ci: run the chromium-step-up E2E project (#75)
feat: handle WebAuthn step-up (SUF-02) on sensitive operations (#75) (#…
test: OIDC social-login setPassword step-up fallback E2E (#90) (#93)