#4676·omnivore

Non-expiring auth credential exposed in SSO redirect URL (?tok=) enables account takeover

Author: 28HusCreated Aug 26, 2026Updated Aug 28, 2026

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:
    1. Deployment uses the default web app config (NEXT_PUBLIC_APP_ENV unset or prod) so Google sign-in posts to /api/auth/vercel/gauth-redirect (isVercel=true). This is the official deployment.
    2. SSO_JWT_SECRET is configured on API and web (standard key in .env.template / self-hosting .env.example).
    3. The victim logs in with Google.
    4. The attacker obtains the tok URL or a log/history record containing it.
  • Severity: Medium (conditional on URL observation; impact is permanent full takeover).

Root cause

packages/api/src/routers/auth/jwt_helpers.tscreateWebAuthToken signs { uid } with JWT_SECRET and no expiresIn (no exp claim):

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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)

  1. Configure a local instance per the repo README with JWT_SECRET, SSO_JWT_SECRET, GAUTH_CLIENT_ID, GAUTH_SECRET, CLIENT_URL, and NEXT_PUBLIC_* vars.
  2. Complete a Google sign-in.
  3. Capture the API 302 Location: https://<CLIENT_URL>/api/client/auth?tok=<ssoToken>.
  4. base64url-decode the middle segment of tok{"authToken":"<JWT>","redirectTo":"...","exp":...}.
  5. base64url-decode the middle segment of authToken{"uid":"<userId>"} (no exp).
  6. Replay against POST /api/graphql with Cookie: auth=<authToken> (or Authorization: <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 exp

Negative controls (boundaries)

  • If ssoJwtSecret is unset, or tok is missing/an array, /api/client/auth redirects to the default home path and does not set the cookie.
  • A tampered or invalid tok fails jwt.verify and sets no cookie (not fail-open).
  • The non-Vercel POST /gauth-redirect path (isVercel=false) does not use tok.
  • This issue does not rely on forging tokens — that would require JWT_SECRET/SSO_JWT_SECRET.

Suggested fix

  1. 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/auth issue/refresh the cookie directly.
  2. Add a short expiresIn to createWebAuthToken with rolling refresh so a leaked credential is not valid forever.
  3. Set Referrer-Policy: no-referrer on /api/client/auth and 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.