Skip to content

verifyAccessToken hardcodes iss as https://api.workos.com; non-default apiHostname refreshes on every request #83

Description

@mitch-fultz

Summary

verifyAccessToken in src/session.ts pins the expected iss claim to the literal https://api.workos.com:

const WORKOS_JWT_ISSUER = 'https://api.workos.com';
async function verifyAccessToken(accessToken: string) {
const JWKS = getJWKS();
try {
await jwtVerify(accessToken, JWKS, { issuer: WORKOS_JWT_ISSUER });

In practice iss is the API host that minted the token (https://api.workos.com in production, https://api.workos-test.com on staging), so any deployment that sets apiHostname / WORKOS_API_HOSTNAME to something other than api.workos.com fails verification on every request. The signature check itself passes (the JWKS URL is derived from apiHostname); only the issuer comparison fails.

Effect

updateSession (used by authkitLoader / authLoader) treats a failed verification as "expired" and refreshes:

async function updateSession(request: Request, debug: boolean): Promise<Session | null> {
const session = await getSessionFromCookie(request.headers.get('Cookie') as string);
const { commitSession, getSession } = await getSessionStorage();
// If no session, just continue
if (!session) {
return null;
}
const hasValidSession = await verifyAccessToken(session.accessToken);
if (hasValidSession) {
// istanbul ignore next
if (debug) console.log('Session is valid');
return session;
}
try {
// istanbul ignore next
if (debug) console.log(`Session invalid. Refreshing access token that ends in ${session.accessToken.slice(-10)}`);
const { organizationId } = getClaimsFromAccessToken(session.accessToken);
// If the session is invalid (i.e. the access token has expired) attempt to re-authenticate with the refresh token
const { accessToken, refreshToken, user, impersonator } =
await getWorkOS().userManagement.authenticateWithRefreshToken({
clientId: getConfig('clientId'),
refreshToken: session.refreshToken,
organizationId,
});

So with a non-default apiHostname every loader run does an authenticateWithRefreshToken round-trip, rotates the refresh token, and emits a new Set-Cookie — even when the access token is still valid. Silent, because verifyAccessToken swallows the jose error, so this only shows up as slow loaders and constant cookie churn (with debug: true it logs Session invalid. Refreshing access token … on every request).

withAuth() is unaffected (it only decodes claims and checks exp).

Repro

  • @workos-inc/authkit-react-router 0.12.2 (also 0.11.0, 0.12.0, 0.12.1; 0.10.0 predates the check)
  • WORKOS_API_HOSTNAME=api.workos-test.com (WorkOS staging, same setup as the public workos/workos-demo .env.example)
  • Sign in, then load any route under authkitLoader twice.

Running jwtVerify with the same JWKS by hand, immediately before the library's call:

jwks: https://api.workos-test.com/sso/jwks/client_… iss: https://api.workos-test.com | sig ok | iss FAIL: unexpected "iss" claim value

Every request then refreshes and carries a fresh Set-Cookie, well inside the token's lifetime.

Suggested fix

Derive the issuer from the same config the JWKS URL already comes from:

await jwtVerify(accessToken, JWKS, { issuer: `https://${getConfig('apiHostname')}` });

That keeps the cross-environment protection the comment above the constant describes (a token from another host still fails, because its iss won't match the configured host), and the comment's claim that iss is "fixed regardless of environment" should be updated — the docs example shows the production value, not an invariant.

Related: #69 (adding iss/aud validation) notes the iss string still needs to be pinned down per environment; this is that case.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions