mastra-ai/mastra · error

State token has expired

Error message

State token has expired

What it means

The Clerk SSO state token is an HMAC-signed, base64-encoded JSON payload that embeds an expiry timestamp `e`. During callback verification, verifyStateToken re-parses the payload and compares the expiry against Date.now(); if the token's epoch-milliseconds have passed, the state can no longer be trusted as a fresh CSRF guard, so the library throws. This is an intentional liveness bound on the OAuth round-trip, not a signature or corruption problem.

Source

Thrown at auth/clerk/src/index.ts:167

 */
async function verifyStateToken(
  stateToken: string,
  secret: string,
): Promise<{ originalState: string; redirectUri: string }> {
  const parts = stateToken.split('.');
  if (parts.length !== 2) {
    throw new Error('Invalid state token format');
  }

  const [payloadB64, signature] = parts;
  const expectedSig = await hmacSign(payloadB64!, secret);
  if (!timingSafeEqual(signature!, expectedSig)) {
    throw new Error('Invalid state token signature');
  }

  const payload = JSON.parse(atob(payloadB64!)) as StatePayload;
  if (payload.e < Date.now()) {
    throw new Error('State token has expired');
  }

  return { originalState: payload.s, redirectUri: payload.r };
}

/**
 * Escape special regex characters in a string.
 */
function escapeRegex(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Derive the Frontend API (FAPI) URL from a Clerk publishable key.
 * The publishable key is: prefix + base64(fapiDomain + "$")
 */
function deriveFapiUrl(publishableKey: string): string {
  const withoutPrefix = publishableKey.replace(/^pk_(test|live)_/, '');

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Have the user restart the SSO login flow so a fresh state token is issued.
  2. Increase the state-token TTL via the provider's configuration/options if legitimate callbacks are slow.
  3. Check server clocks (NTP) on all instances to eliminate skew between sign and verify.
  4. Improve UX by handling the thrown error and redirecting the user back to the login initiation instead of showing a raw error.

Example fix

// before: raw error surfaces on callback
await provider.verifyCallbackState(state);
// after: restart flow on expiry
try {
  await provider.verifyCallbackState(state);
} catch (e) {
  if ((e as Error).message === 'State token has expired') {
    return res.redirect('/auth/sso/login'); // re-initiate
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

function isLikelyExpired(stateToken: string): boolean {
  try {
    const payload = JSON.parse(atob(stateToken.split('.')[0]));
    return typeof payload.e === 'number' && payload.e < Date.now();
  } catch {
    return false;
  }
}

Try / catch

try {
  await provider.verifyCallbackState(state);
} catch (e) {
  if ((e as Error).message === 'State token has expired') {
    return restartLoginFlow(); // redirect to SSO initiation
  }
  throw e;
}

Prevention

When it happens

Trigger: A user starts SSO login via _attachSSOProvider's getAuthorizationUri, which calls createStateToken with a TTL, but the provider's callback arrives after that TTL and redirectUri → verifyStateToken runs `payload.e < Date.now()` → throw.

Common situations: User leaves the login page open and resumes hours later; very long serverless cold starts or queues delaying the callback; clock skew between the node that signed the token and the node verifying it; an unusually short expiry configured for the state token.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/503616e4c0330c7e. Report an issue: GitHub.