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.
Summary
verifyAccessTokeninsrc/session.tspins the expectedissclaim to the literalhttps://api.workos.com:authkit-react-router/src/session.ts
Lines 784 to 789 in 91abc44
In practice
issis the API host that minted the token (https://api.workos.comin production,https://api.workos-test.comon staging), so any deployment that setsapiHostname/WORKOS_API_HOSTNAMEto something other thanapi.workos.comfails verification on every request. The signature check itself passes (the JWKS URL is derived fromapiHostname); only the issuer comparison fails.Effect
updateSession(used byauthkitLoader/authLoader) treats a failed verification as "expired" and refreshes:authkit-react-router/src/session.ts
Lines 200 to 228 in 91abc44
So with a non-default
apiHostnameevery loader run does anauthenticateWithRefreshTokenround-trip, rotates the refresh token, and emits a newSet-Cookie— even when the access token is still valid. Silent, becauseverifyAccessTokenswallows thejoseerror, so this only shows up as slow loaders and constant cookie churn (withdebug: trueit logsSession invalid. Refreshing access token …on every request).withAuth()is unaffected (it only decodes claims and checksexp).Repro
@workos-inc/authkit-react-router0.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 publicworkos/workos-demo.env.example)authkitLoadertwice.Running
jwtVerifywith the same JWKS by hand, immediately before the library's call: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:
That keeps the cross-environment protection the comment above the constant describes (a token from another host still fails, because its
isswon't match the configured host), and the comment's claim thatissis "fixed regardless of environment" should be updated — the docs example shows the production value, not an invariant.Related: #69 (adding
iss/audvalidation) notes theissstring still needs to be pinned down per environment; this is that case.