Skip to Content
ConceptsSessions and tokens

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 }
TokenPurposeVerified byLifetime
access_tokenAuthenticate every backend requestYour backend (RS256 + JWKS)1 hour (configurable)
id_tokenDisplay the user without an extra API callYour client (RS256 + JWKS + nonce)1 hour
refresh_tokenMint new access tokens without re-authing the userSynq30 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:

  1. Validates that this refresh token is still active.
  2. Mints a fresh access_token + id_token + refresh_token triple.
  3. 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

TokenDefault lifetimeTunable?
access_token1 hourper-App, 5 min – 24 hours
id_token1 hourper-App, matches access_token
refresh_token30 days slidingper-App, 1–365 days
device_code (RFC 8628)10 minutesno
user_code (RFC 8628)10 minutesno
auth_code60 secondsno
consent_grantindefinite until revokedno

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:

  1. Signature — RS256, signed by a key in https://synq.id/.well-known/jwks.json.
  2. Issueriss === 'https://synq.id'.
  3. Audienceaud === <your brand slug>.
  4. Expiryexp > now().
  5. Not-beforeiat <= now() (handles modest clock skew).
  6. Scopescope includes 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 kid for 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/js ships.

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 azp came 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/revoked

Prefer 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):

SettingValueWhy
HttpOnlytrueJS can’t steal it
SecuretrueHTTPS only
SameSiteLaxsurvives the OAuth redirect from Synq
Path/available app-wide
Domain(host-scoped)unless you need to share across subdomains
Lifetimematch 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/revoke on the refresh token, plus clearing your session.
Last updated on