Tokens & Claims
What each token looks like, how long it lives, and how to trust it.
Token lifetimes
| Token | Lifetime | Format |
|---|---|---|
| Access token | 15 minutes (expires_in: 900) | RS256 JWT, kid in header |
| Refresh token | 30 days, rotates on every use | Opaque 43-char base64url string |
| Authorization code | 90 seconds, single-use | Opaque 43-char base64url string |
Browser session (camel_session cookie) | 30 days, sliding | Opaque UUID (HttpOnly cookie) |
No ID token is issued. Identity claims come from
GET /oauth/userinfowith 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"
}| Claim | Meaning | Notes |
|---|---|---|
iss | Issuer — fixed to https://accounts.camelcreatives.com | Verify equality |
sub | User UUID — the permanent cross-app identity | Key your user rows on this |
aud | Array containing your client_id | Verify your ID is present; rejects tokens minted for other apps |
exp / iat | Expiry / issued-at (unix seconds) | 15-minute window |
jti | Unique token ID | Server keeps revocation records keyed by this |
scope | Space-separated granted scopes | Enforce per-endpoint authorization from this |
client_id | The client the token was issued to | Redundant 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:
- Every successful
grant_type=refresh_tokenexchange marks the presented token used and returns a new access + refresh pair. - 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_grantwith descriptionrefresh token reuse detected, all sessions for this client were revoked. - 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:
- Signature — verify against
/oauth/jwks, selecting the key whosekidmatches the JWT header - Algorithm — accept only
RS256 - Issuer —
iss === "https://accounts.camelcreatives.com" - Audience — your
client_id∈aud - Expiry —
exp > now(allow ~30–60 s clock leeway) - Scopes — does
scopeinclude what the endpoint needs? - (Optional, strict mode) — confirm the
jtihasn'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}orPOST /oauth/revokedeletes the consent, revokes the refresh-token family, and kills live access tokens for that user+client pair (via thejtirecords).- 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 type | Access token | Refresh token |
|---|---|---|
| SPA | Memory preferred; sessionStorage acceptable | Avoid localStorage unless the threat model allows it |
| Mobile | Encrypted app storage | Keystore / encrypted prefs |
| Confidential backend | Server memory/cache | Encrypted database column |
The React SDK defaults to memory-only storage for exactly this reason.