actualbudget/actual · error

token-expired

token-expired

Error message

token-expired

What it means

The sync server validates the session's expiry: sessions carry an expires_at timestamp (unix seconds), and TOKEN_EXPIRATION_NEVER (-1) marks never-expiring tokens. If expires_at is not -1 and expires_at * 1000 is at or before the current time, validateSession returns 401 with reason 'token-expired'. The token is known to the server but its lifetime has elapsed.

Source

Thrown at packages/sync-server/src/util/validate-user.ts:33

  }

  const session = getSession(token);

  if (!session) {
    res.status(401);
    res.send({
      status: 'error',
      reason: 'unauthorized',
      details: 'token-not-found',
    });
    return null;
  }

  if (
    session.expires_at !== TOKEN_EXPIRATION_NEVER &&
    session.expires_at * MS_PER_SECOND <= Date.now()
  ) {
    res.status(401);
    res.send({
      status: 'error',
      reason: 'token-expired',
    });
    return null;
  }

  return session;
}

export function validateAuthHeader(req: Request) {
  // fallback to trustedProxies when trustedAuthProxies not set
  const trustedAuthProxies: string[] =
    config.get('trustedAuthProxies') ?? config.get('trustedProxies');
  // ensure the first hop from our server is trusted
  const peer = req.socket.remoteAddress;
  if (peer === undefined) {
    console.error(`Header Auth Login attempted but there was no defined peer.`);

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Re-authenticate to get a fresh token (log in again via /login or /openid/login); the client should handle 401 reason 'token-expired' by refreshing automatically.
  2. Increase the server's token expiration setting (token_expiration_int in the config / TOKEN_EXPIRATION env) if sessions expire too quickly.
  3. Verify server clock (NTP) — a fast clock invalidates tokens early.
  4. For long-lived automation/API usage, configure never-expiring behavior where appropriate (expires_at = -1) or implement token refresh.

Example fix

// before: blindly reusing stored token
const res = await fetch(url + '/sync', { headers: { 'x-actual-token': storedToken } });
// after: handle expiry by re-authenticating
let res = await fetch(url + '/sync', { headers: { 'x-actual-token': storedToken } });
if (res.status === 401) {
  storedToken = await login(password); // refresh and retry
  res = await fetch(url + '/sync', { headers: { 'x-actual-token': storedToken } });
}
Defensive patterns

Strategy: retry

Validate before calling

const expiresAtSec = session?.expires_at;
if (expiresAtSec !== -1 && expiresAtSec * 1000 <= Date.now()) {
  throw new Error('token expired; re-authenticate before calling the sync server');
}

Type guard

function isSessionValid(s: { expires_at: number } | null): boolean {
  return s != null && (s.expires_at === -1 || s.expires_at * 1000 > Date.now());
}

Try / catch

try {
  const res = await fetch(url + '/sync', { headers: { 'x-actual-token': token } });
  if (res.status === 401 && (await res.json()).reason === 'token-expired') {
    token = await login(credentials); // refresh token, then retry
    return await syncRequest(token);
  }
} catch (e) {
  logger.error('sync failed after token refresh', e);
}

Prevention

When it happens

Trigger: Any authenticated sync-server request where the stored session's expires_at (unix seconds) is <= now and not -1. Happens when a client keeps a token longer than the server's configured session expiration (openId/header-auth token lifetimes or the server's token expiration setting) and reuses it after expiry.

Common situations: Desktop or mobile app left open across a long period with an expired token; clocks skewed between client and server making a valid token look expired; server-side token_expiration_int set to a short duration (e.g. 3600s) while the app expects long-lived sessions.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/fb90b8f24f4d5fe7. Report an issue: GitHub.