Non-expiring auth credential exposed in SSO redirect URL (?tok=) enables account takeover
Summary
In the default (prod appEnv / Vercel) Google sign-in flow, the API puts the permanent web auth JWT (authToken, the auth cookie value) inside an SSO JWT that is carried in the redirect URL as /api/client/auth?tok=<ssoToken>. JWT payloads are signed, not encrypted, so anyone who observes that URL (access logs, CDN/proxy/Vercel logs, browser history, HAR export, shared link) can decode the tok parameter and recover authToken — a credential that never expires and is accepted by the API with no session binding or revocation. This enables persistent account takeover.
Impact and preconditions
- Impact: full, permanent account takeover (read articles/notes, manage subscriptions, run authenticated GraphQL operations). The credential cannot be invalidated by password change or logout — only by rotating
JWT_SECRET. - Preconditions:
- Deployment uses the default web app config (
NEXT_PUBLIC_APP_ENVunset orprod) so Google sign-in posts to/api/auth/vercel/gauth-redirect(isVercel=true). This is the official deployment. SSO_JWT_SECRETis configured on API and web (standard key in.env.template/ self-hosting.env.example).- The victim logs in with Google.
- The attacker obtains the
tokURL or a log/history record containing it.
- Deployment uses the default web app config (
- Severity: Medium (conditional on URL observation; impact is permanent full takeover).
Root cause
packages/api/src/routers/auth/jwt_helpers.ts — createWebAuthToken signs { uid } with JWT_SECRET and no expiresIn (no exp claim):
export async function createWebAuthToken(userId: string): Promise<string | undefined> {
const authToken = await signToken({ uid: userId }, env.server.jwtSecret) // no exp -> never expires
return authToken as string
}packages/api/src/utils/sso.ts — that permanent authToken is embedded in an SSO JWT and placed in the URL query string:
export const createSsoToken = (authToken: string, redirectTo: string): string => {
const ssoToken = jwt.sign({ authToken, redirectTo }, env.server.ssoJwtSecret, { expiresIn: '1d' })
return ssoToken
}
export const ssoRedirectURL = (ssoToken: string): string => {
const u = new URL(homePageURL())
u.pathname = 'api/client/auth'
u.searchParams.append('tok', ssoToken) // credential enters URL query param
return u.toString()
}The expiresIn: '1d' only bounds the SSO wrapper; the embedded authToken has no expiry.
packages/api/src/routers/auth/google_auth.ts — triggered in the isVercel branch:
if (isVercel) {
const ssoToken = createSsoToken(authToken, redirectURL)
redirectURL = ssoRedirectURL(ssoToken)
}packages/web/pages/api/client/auth.ts — the web endpoint verifies the SSO token and writes the embedded credential as the auth cookie:
const tok = req.query.tok
if (ssoJwtSecret && tok && !Array.isArray(tok)) {
const payload = jwt.verify(tok, ssoJwtSecret) as AuthPayload
res.setHeader('Set-Cookie', serialize('auth', payload.authToken, cookieOptions))
res.writeHead(302, { Location: payload.redirectTo })
}packages/api/src/utils/auth.ts — the API accepts that credential unconditionally:
return jwt.verify(token, env.server.jwtSecret) as Claims // no session binding, no revocation(used from packages/api/src/apollo.ts: req?.cookies?.auth || req?.headers?.authorization).
Why the exposure is recoverable
A JWT signature provides integrity, not confidentiality. The tok parameter's payload is base64url-encoded JSON, so decoding tok yields the full authToken, and decoding authToken yields { "uid": ... }. No key is needed to read it. Verified with an HS256 equivalent of the exact jwt.sign/jwt.verify calls above.
Minimal reproduction (local, no attacker needed to demonstrate the mechanism)
- Configure a local instance per the repo README with
JWT_SECRET,SSO_JWT_SECRET,GAUTH_CLIENT_ID,GAUTH_SECRET,CLIENT_URL, andNEXT_PUBLIC_*vars. - Complete a Google sign-in.
- Capture the API
302 Location:https://<CLIENT_URL>/api/client/auth?tok=<ssoToken>. - base64url-decode the middle segment of
tok→{"authToken":"<JWT>","redirectTo":"...","exp":...}. - base64url-decode the middle segment of
authToken→{"uid":"<userId>"}(noexp). - Replay against
POST /api/graphqlwithCookie: auth=<authToken>(orAuthorization: <authToken>) → fully authenticated as the victim.
Redacted example of what tok reveals (placeholders, not real tokens):
tok payload: {"authToken":"eyJhbGciOiJIUzI1NiIs...","redirectTo":"https://omnivore.app/home","exp":17…}
authToken payload: {"uid":"9a…"} // no expNegative controls (boundaries)
- If
ssoJwtSecretis unset, ortokis missing/an array,/api/client/authredirects to the default home path and does not set the cookie. - A tampered or invalid
tokfailsjwt.verifyand sets no cookie (not fail-open). - The non-Vercel
POST /gauth-redirectpath (isVercel=false) does not usetok. - This issue does not rely on forging tokens — that would require
JWT_SECRET/SSO_JWT_SECRET.
Suggested fix
- Do not pass persistent credentials in URLs: use a one-time, short-lived server-side exchange token (e.g., stored in Redis, single-use), or have
/api/client/authissue/refresh the cookie directly. - Add a short
expiresIntocreateWebAuthTokenwith rolling refresh so a leaked credential is not valid forever. - Set
Referrer-Policy: no-referreron/api/client/authand sanitize query strings in access logs.
Coordination
Reported per SECURITY.md (Issues tab). Full call chain, raw HTTP reconstruction, and verification notes are available on request. Happy to provide any additional evidence or a patch draft.
Source: omnivore-app/omnivore