Sessions and tokens
Everything Synq hands you, what to do with it, when it expires, how to keep it alive, and how to invalidate it. This is the page to bookmark.
The three tokens
A successful authorization_code exchange returns three tokens.
Three different purposes; three different lifecycles.
interface TokenResponse {
access_token: string // RS256 JWT
token_type: 'Bearer'
expires_in: number // access_token lifetime, in seconds
refresh_token?: string // opaque, single-use, rotates on every use
id_token?: string // RS256 JWT, only when 'openid' scope granted
scope?: string // granted scopes, space-separated
}| Token | Purpose | Verified by | Lifetime |
|---|---|---|---|
access_token | Authenticate every backend request | Your backend (RS256 + JWKS) | 1 hour (configurable) |
id_token | Display the user without an extra API call | Your client (RS256 + JWKS + nonce) | 1 hour |
refresh_token | Mint new access tokens without re-authing the user | Synq | 30 days, sliding |
access_token — the workhorse
A signed RS256 JWT. Send it on every authenticated backend request as
Authorization: Bearer <token>. Your backend verifies it locally
against Synq’s JWKS — no per-request round-trip to Synq.
interface AccessTokenPayload {
sub: string // user id ("subject")
aud: string // brand slug (NOT client_id)
azp: string // client_id that obtained this token
scope: string // granted scopes, space-separated
iat: number // issued-at, unix seconds
exp: number // expires-at, unix seconds
auth_time?: number // when the user actually authenticated
act?: { // present on device-flow tokens only
type: 'agent'
client_id: string
}
}The aud claim is the Brand slug, not your client_id. This
lets the same backend service accept tokens issued to any of the
multiple Apps under the same Brand (web + mobile + CLI share the
brand and the same backend).
id_token — the profile snapshot
Returned alongside the access token when openid is in the requested
scope. Carries the user’s profile claims at the moment of sign-in.
Use it on your client to render the signed-in user without an extra fetch. Use it on your server to capture the user’s email at sign-in (remember: Synq does not persist email on the user record).
interface IdTokenPayload {
sub: string // user id (same as access_token.sub)
aud: string // your client_id (not brand slug)
iss: string // 'https://synq.id'
iat: number
exp: number
auth_time: number
nonce?: string // echoed from your authorization request
email?: string // when 'email' scope granted
email_verified?: boolean
given_name?: string // when 'profile' scope granted
family_name?: string
preferred_username?: string
picture?: string // URL
}Verify the nonce. Compare it against the nonce you generated in
step 2 of the Quickstart. If they don’t match, drop
the token; you may be looking at a replay.
refresh_token — the renewal key
Opaque. Single-use. When you exchange it at /oauth2/token with
grant_type=refresh_token, Synq:
- Validates that this refresh token is still active.
- Mints a fresh
access_token+id_token+refresh_tokentriple. - Invalidates the old refresh token immediately.
The 30-day lifetime is sliding — every use resets it. A daily active user stays signed in indefinitely; a user gone for 31 days needs to re-authenticate.
m2m clients (client_credentials grant) never receive a refresh
token. They re-call the token endpoint when they need a new access
token.
Lifetimes — the full table
| Token | Default lifetime | Tunable? |
|---|---|---|
access_token | 1 hour | per-App, 5 min – 24 hours |
id_token | 1 hour | per-App, matches access_token |
refresh_token | 30 days sliding | per-App, 1–365 days |
device_code (RFC 8628) | 10 minutes | no |
user_code (RFC 8628) | 10 minutes | no |
auth_code | 60 seconds | no |
consent_grant | indefinite until revoked | no |
Shorter access-token lifetimes pair well with longer refresh-token lifetimes for security: a leaked access token expires fast; the refresh token (which only your backend ever sees) carries the long tail.
Verifying access tokens
Your backend should verify every incoming access_token. The full
checklist:
- Signature — RS256, signed by a key in
https://synq.id/.well-known/jwks.json. - Issuer —
iss === 'https://synq.id'. - Audience —
aud === <your brand slug>. - Expiry —
exp > now(). - Not-before —
iat <= now()(handles modest clock skew). - Scope —
scopeincludes whatever permissions this endpoint requires.
jose does steps 1–5 automatically; you check 6 in application code:
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://synq.id/.well-known/jwks.json'))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://synq.id',
audience: process.env.SYNQ_BRAND_SLUG,
clockTolerance: '5 seconds',
})
return payload as AccessTokenPayload
}
export function requireScope(scope: string) {
return (payload: AccessTokenPayload) => {
if (!payload.scope?.split(' ').includes(scope)) {
throw new Error('forbidden: missing scope ' + scope)
}
}
}Why aud = brand slug?
So that one backend service can accept tokens issued by any App under the same Brand. Web + mobile + CLI all hit the same backend; they share the brand audience.
If you have apps under multiple Brands that share a backend, allow multiple audiences:
audience: ['marketplace', 'admin-tools'],Caching JWKS
createRemoteJWKSet caches the JWKS for 10 minutes by default and
refetches automatically on a kid cache miss. You do not need
to manage the cache. Just construct the set once at module scope and
reuse it.
If you write your own verifier in a language without jose:
- Cache by
kidfor at least 10 minutes. - On a miss, refetch. Do not cache misses.
- Rotate cache on a process basis; do not share between processes unless you also share invalidation.
Refreshing access tokens
The flow:
async function refresh(refreshToken: string) {
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.SYNQ_CLIENT_ID!,
client_secret: process.env.SYNQ_CLIENT_SECRET!,
})
const r = await fetch('https://synq.id/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!r.ok) throw new Error('refresh_failed')
return r.json() as Promise<TokenResponse>
}When to refresh
Three sensible policies, pick one:
- Eager — refresh in the background N seconds before
exp. Best user experience; no 401-then-retry. Set N = 60. - Lazy — wait for a 401 from your backend, refresh, retry the failed request once. Simpler client; one round-trip cost per expiry.
- Hybrid — eager refresh while the tab is active; lazy when
returning from background. What
@synqid/jsships.
The single-use trap
Synq rotates refresh tokens. If two requests race the same refresh
token (e.g. an SPA with multiple tabs), one will succeed and the
other will fail with error=invalid_grant.
The safe pattern: single-flight your refreshes. The first request triggers the refresh; concurrent requests wait on the same promise.
let inflight: Promise<TokenResponse> | null = null
async function getValidToken(session: Session) {
if (Date.now() / 1000 < session.tokens.exp - 30) {
return session.tokens.access_token
}
if (!inflight) {
inflight = refresh(session.tokens.refresh_token!).finally(() => {
inflight = null
})
}
const next = await inflight
session.tokens = next
return next.access_token
}@synqid/js does this for you out of the box.
When refresh fails
If refresh_token is consumed, expired, or revoked, Synq returns
{ error: 'invalid_grant' }. The user has to re-authenticate. Treat
this as a sign-in screen, not a stack trace.
Revoking tokens
POST /oauth2/revoke invalidates a token. RFC 7009 envelope.
const body = new URLSearchParams({
token: refreshToken,
token_type_hint: 'refresh_token',
client_id: process.env.SYNQ_CLIENT_ID!,
client_secret: process.env.SYNQ_CLIENT_SECRET!,
})
await fetch('https://synq.id/oauth2/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// Response: 200 OK, empty body. Always 200, even if the token was
// already invalid or never existed (RFC 7009 §2.2).Use cases:
- User clicks sign-out — revoke the refresh token. The access token will expire on its own within an hour.
- API key suspected leaked — revoke (
DELETE /users/me/api-keys/:id). - User unlinks a provider — revoke any tokens whose
azpcame from that link (cleanup; not strictly required).
Introspecting tokens
POST /oauth2/introspect answers “is this token currently valid?”
without your backend doing JWT verification.
const r = await fetch('https://synq.id/oauth2/introspect', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
},
body: new URLSearchParams({ token: accessToken }),
})
const result = await r.json()
// { active: true, scope, client_id, token_type, exp, iat, sub, iss }
// active = false when the token is invalid/expired/revokedPrefer local JWT verification when you can — it’s free and has no extra latency. Use introspection for:
- Opaque-token scenarios (you didn’t get a JWT, just an opaque string).
- Audit / monitoring services that don’t want JWKS dependencies.
- Validating long-lived API keys.
Sessions in your app
Synq tokens give you the identity. Your app still maintains a session — typically a session cookie wrapping the refresh token (so the user is automatically signed back in on a fresh page load).
Recommended cookie shape (server-rendered web apps):
| Setting | Value | Why |
|---|---|---|
HttpOnly | true | JS can’t steal it |
Secure | true | HTTPS only |
SameSite | Lax | survives the OAuth redirect from Synq |
Path | / | available app-wide |
Domain | (host-scoped) | unless you need to share across subdomains |
| Lifetime | match refresh token (30d sliding) | logical session length |
Your session payload typically holds:
interface Session {
userId: string
tokens: {
access_token: string
refresh_token: string
expires_at: number // unix seconds
}
// Plus your own app state.
}Iron Session, NextAuth’s encrypted-cookie store, and signed JWE
cookies are all fine. Don’t put the refresh token in localStorage;
it’s a credential and JS-accessible storage is XSS-exposed.
Mental model summary
- Access token = bearer token for backend calls. Verify locally against JWKS. Audience = brand slug. ~1 hour.
- ID token = profile snapshot for the client. Verify nonce. ~1 hour.
- Refresh token = renewal key. Opaque, single-use, rotates. ~30 days sliding.
- Verification stays local. Synq is not in your hot path.
- Refresh is single-flight. Don’t let two requests race the same token.
- Revocation is a POST. Sign-out is
/oauth2/revokeon the refresh token, plus clearing your session.