slymnoyann/hey-1 · critical · Error

Refresh token is invalid or expired

Error message

Refresh token is invalid or expired

What it means

Thrown when the refresh mutation returns a ForbiddenError typename, meaning the server explicitly rejected the refresh token as invalid or expired. Unlike unknown failures, this is a definitive verdict: the session cannot be renewed, so signOut() is called and the error propagates to terminate the auth flow.

Source

Thrown at src/helpers/tokenManager.ts:42

      if (refreshResult.__typename === "AuthenticationTokens") {
        const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
          refreshResult;

        if (!newAccessToken || !newRefreshToken) {
          throw new Error("Missing tokens in refresh response");
        }

        signIn({
          accessToken: newAccessToken,
          refreshToken: newRefreshToken
        });

        return newAccessToken;
      }

      if (refreshResult.__typename === "ForbiddenError") {
        signOut();
        throw new Error("Refresh token is invalid or expired");
      }

      if (attempt < MAX_RETRIES - 1) {
        await new Promise((resolve) =>
          setTimeout(resolve, 2 ** attempt * 1000)
        );
      }
    }

    throw new Error("Unknown error during token refresh");
  } finally {
    refreshPromise = null;
  }
};

export const refreshTokens = (refreshToken: string): Promise<string> => {
  if (!refreshPromise) {
    refreshPromise = executeTokenRefresh(refreshToken);

View on GitHub (pinned to 88c8f9d553)

Solutions

  1. Ensure signOut() fully clears persisted tokens so the next load starts clean
  2. Redirect the user to the login page when this error is caught, rather than showing a generic error
  3. If tokens expire too quickly for your use case, extend refresh token TTL server-side or implement silent refresh before expiry
  4. Check for environment mismatch (staging token against production API) if expiry seems premature

Example fix

// before
if (refreshResult.__typename === "ForbiddenError") {
  signOut();
  throw new Error("Refresh token is invalid or expired");
}

// after (caller side)
try {
  await refreshTokens(refreshToken);
} catch (e) {
  if (e instanceof Error && e.message.includes("invalid or expired")) {
    window.location.href = "/login?reason=session_expired";
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

try {
  const decoded = JSON.parse(atob(refreshToken.split(".")[1]));
  if (decoded.exp && decoded.exp * 1000 < Date.now()) {
    signOut(); // proactively expire the session
  }
} catch { /* opaque token; let the server decide */ }

Type guard

const isForbiddenRefresh = (r: unknown): r is { __typename: "ForbiddenError" } =>
  typeof r === "object" && r !== null && (r as { __typename?: unknown }).__typename === "ForbiddenError";

Try / catch

try {
  await refreshTokens(refreshToken);
} catch (e) {
  if (e instanceof Error && e.message === "Refresh token is invalid or expired") {
    window.location.href = "/login?reason=session_expired";
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Refreshing with a refresh token past its server-side expiry, a token revoked by logout-all-devices or password change, a token signed with a stale secret after backend rotation, or a token belonging to a deleted account.

Common situations: User returning to the app after a long absence with an expired persisted token, token stored under a different environment (staging token sent to prod), JWT secret rotation on the server invalidating old tokens, or the same account being used after credentials changed.

Understand the failure class

Related errors


AI-assisted analysis of slymnoyann/hey-1@88c8f9d553 (2026-08-28). Data as JSON: /api/errors/2ddb42027efdb375. Report an issue: GitHub.