Core Concepts
Tokens & Claims

Tokens & Claims

What each token looks like, how long it lives, and how to trust it.

Token lifetimes

TokenLifetimeFormat
Access token15 minutes (expires_in: 900)RS256 JWT, kid in header
Refresh token30 days, rotates on every useOpaque 43-char base64url string
Authorization code90 seconds, single-useOpaque 43-char base64url string
Browser session (camel_session cookie)30 days, slidingOpaque UUID (HttpOnly cookie)

No ID token is issued. Identity claims come from GET /oauth/userinfo with your bearer access token. The discovery document lists RS256 as the supported signing algorithm; treat the access-token JWT itself as your identity artifact.

Access token anatomy

An RS256 JWT with a kid header so verifiers can pick the right JWKS key:

// header
{
  "alg": "RS256",
  "kid": "2026-01",
  "typ": "JWT"
}
// payload
{
  "iss": "https://accounts.camelcreatives.com",
  "sub": "b3f1c92e-4d5a-4f6b-8a7c-9e0d1f2a3b4c",
  "aud": ["bajeti-web-x7k2p9"],
  "exp": 1777300000,
  "iat": 1777299100,
  "jti": "6c1f8a2e-90b4-4c3d-a5e6-7f8a9b0c1d2e",
  "scope": "openid profile email",
  "client_id": "bajeti-web-x7k2p9"
}
ClaimMeaningNotes
issIssuer — fixed to https://accounts.camelcreatives.comVerify equality
subUser UUID — the permanent cross-app identityKey your user rows on this
audArray containing your client_idVerify your ID is present; rejects tokens minted for other apps
exp / iatExpiry / issued-at (unix seconds)15-minute window
jtiUnique token IDServer keeps revocation records keyed by this
scopeSpace-separated granted scopesEnforce per-endpoint authorization from this
client_idThe client the token was issued toRedundant with aud; handy in logs

Refresh tokens & theft detection

Refresh capability is built into every code exchange and refresh — there is no separate offline_access scope.

Rotation protocol:

  1. Every successful grant_type=refresh_token exchange marks the presented token used and returns a new access + refresh pair.
  2. Presenting an already-rotated token is treated as theft: the server revokes the entire family — every refresh token and live access token that client holds for that user. The API answers 400 invalid_grant with description refresh token reuse detected, all sessions for this client were revoked.
  3. Refreshed scope is re-derived from the stored consent, not from your request.

Correct client behavior:

async function callApi(url) {
  let res = await fetch(url, { headers: authHeader(tokens) });
 
  if (res.status === 401) {
    const refreshed = await refreshTokens(tokens.refreshToken);
    if (!refreshed) return signOut(); // expired OR family revoked — re-login required
    res = await fetch(url, { headers: authHeader(refreshed) });
  }
  return res;
}
 
async function refreshTokens(refreshToken) {
  const res = await fetch(`${ISSUER}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      // confidential clients: + client_secret
    }),
  });
  if (!res.ok) return null;
  const next = await res.json();
  saveTokens(next); // overwrite BOTH tokens — old refresh token is now dead
  return next;
}

Never run two refreshes concurrently on the same token — one of them will look like reuse.

Verifying access tokens (resource servers)

Checklist, in order:

  1. Signature — verify against /oauth/jwks, selecting the key whose kid matches the JWT header
  2. Algorithm — accept only RS256
  3. Issueriss === "https://accounts.camelcreatives.com"
  4. Audience — your client_idaud
  5. Expiryexp > now (allow ~30–60 s clock leeway)
  6. Scopes — does scope include what the endpoint needs?
  7. (Optional, strict mode) — confirm the jti hasn't been revoked via a userinfo round-trip

Full working code per language: Backend Token Verification.

JWKS & key rotation

curl -s "$ISSUER/oauth/jwks"
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-01",
      "n": "0vx7agoebGcQSuuPiLJXZptN…",
      "e": "AQAB"
    }
  ]
}

Served with Cache-Control: public, max-age=300 — cache for ~5 minutes, then re-fetch. During rotation both old and new keys are published simultaneously (15-minute overlap window), so tokens signed by either key keep verifying while your cache catches up. Always select keys by kid; never hardcode a single key.

Revocation semantics

Revocation in Camel Accounts is immediate, not eventual:

  • DELETE /connections/{client_id} or POST /oauth/revoke deletes the consent, revokes the refresh-token family, and kills live access tokens for that user+client pair (via the jti records).
  • Deleting a client in the console cascades the same way for all its users.
  • A password reset revokes all browser sessions of that user.

If you implement strict jti checking, revoked tokens fail within seconds of a revoke action; otherwise they die naturally at exp ≤ 15 minutes later.

Storage guidance

Client typeAccess tokenRefresh token
SPAMemory preferred; sessionStorage acceptableAvoid localStorage unless the threat model allows it
MobileEncrypted app storageKeystore / encrypted prefs
Confidential backendServer memory/cacheEncrypted database column

The React SDK defaults to memory-only storage for exactly this reason.