JWT Sessions

User-bound JWT access tokens — how they're minted, refreshed, elevated for step-up, and revoked.

Last updated May 19, 2026

When an actual end user is acting — a passenger booking a ride, a driver going online, a tenant admin opening the ops console — the call needs to be tied to them, not to the tenant. BetterSuite issues a JWT session for that.

How sessions are minted

The identity service exposes one mutation per flow. Each ends by returning a typed AuthFlowResponse union, and the success variant (AuthSuccess) carries the tokens.

FlowMutationNotes
Phone + OTPstartPhoneVerificationverifyOtpThe marketplace-app default.
Email + OTPstartEmailLoginverifyEmailLoginCommon for vendor / admin apps.
Email + passwordloginWithPasswordUsed by the dashboards.
PasskeyWebAuthn ceremony → completePasskeyAuthenticationRecommended for the Owner Dashboard.
RefreshrefreshTokenRenews without re-prompting the user.

A successful AuthSuccess response looks like:

{
  "data": {
    "verifyOtp": {
      "__typename": "AuthSuccess",
      "accountId": "01923a4b-...",
      "accessToken": "eyJhbGciOi...",
      "refreshToken": "eyJhbGciOi...",
      "expiresIn": 900,
      "nextSteps": []
    }
  }
}

expiresIn is the access token TTL in seconds. There's no explicit expiresAt ISO timestamp — compute it client-side from now + expiresIn if you want one.

Note that both tokens are JWTs (the refresh token is opaque-looking only because it carries different claims; same HS256 signature). Don't try to introspect the refresh token client-side — treat it as a sealed blob.

Sending the access token

Same header pattern as elsewhere, but with the JWT:

Authorization: Bearer eyJhbGciOi...

The auth middleware decodes anything in this header as an HS256 access token. If both Authorization: Bearer and X-Api-Key are present, the JWT wins. On a valid token, the middleware strips any client-sent x-tenant-id/x-account-id/x-role/x-session-id/x-partnership-id headers and re-injects them from the JWT's claims — you can't forge identity from the client side.

Token lifetimes

TokenDefault TTLSource
Access token15 minutes (access_token_ttl_seconds = 900)IdentityCacheTtlConfig
Refresh token7 days (refresh_token_ttl_seconds = 604800)IdentityCacheTtlConfig

Both are configurable per environment via defaults.toml / env vars (IDENTITY__CACHE__ACCESS_TOKEN_TTL_SECONDS, IDENTITY__CACHE__REFRESH_TOKEN_TTL_SECONDS). Local dev runs with 24-hour access / 30-day refresh to reduce login churn while iterating.

There is no separate "idle vs hard cap" model today — the refresh token has a single absolute expiry. Each successful refreshToken call mints a brand-new refresh JWT and revokes the previous one's jti from Redis, so a token that's been refreshed is dead even if it hasn't reached its exp yet.

Refresh flow

mutation Refresh($input: RefreshTokenInput!) {
  refreshToken(input: $input) {
    accessToken
    refreshToken
    expiresIn
  }
}

The server checks: (1) signature & expiry on the refresh JWT, (2) the JTI hash matches what's stored on the session row, (3) the session isn't revoked. On success it rotates the JTI — the old refresh token is dead, the new one is returned. Always replace the value in storage immediately; replaying the previous refresh token returns INVALID_REFRESH_TOKEN.

If the session was revoked (logout, admin action, password change), you'll see SESSION_REVOKED instead. If the session can't be found at all, SESSION_NOT_FOUND.

Step-up elevation

A small set of destructive actions require a fresh password re-prompt even when the user is already authenticated. API key rotation is the canonical example; account deletion, password change, and ownership transfer fall in the same bucket.

The flow:

  1. Call requestStepUp(input: { password: "…" }). The server verifies the password against the session's account.
  2. The response is { token, expiresAt }. The token is a JWT with audience "step-up" and a TTL of 5 minutes (ELEVATION_TTL_SECS = 300).
  3. Attach the token to the next mutation as the X-Elevation header.
  4. The auth middleware verifies signature + audience + tenant/subject match against the bearer JWT, then sets an internal x-elevation-jti for the resolver.
  5. The gated resolver calls require_elevation, which consumes the JTI from Redis. Tokens are single-shot — one successful mutation, then it's gone.

If anything fails — missing header, wrong audience, expired, replay, or Redis hiccup — the resolver returns STEP_UP_REQUIRED. Re-prompt and start over.

Claims

Access token claims:

ClaimWhat it is
subAccount ID (UUID)
sidSession ID
tidTenant ID
kidSigning key id (currently always "default")
appApp context, snake_case — taxi, shop, parking, service, super, platform_admin, tenant_admin, partner_admin
roleRole, snake_case — tenant_owner, tenant_admin, platform_admin, partner_admin, operator, driver, merchant, service_provider, park_spot_provider, customer
pidPartnership ID, only present on partner-staff sessions
expExpiry, Unix timestamp
iss / audCurrently "any" / "any" and not validated; reserved for future multi-issuer setups
jtiUnique token id

You generally don't need to read these client-side — the server reads them, validates them, and presents the result as a RequestContext. Reading them client-side is fine for displaying the user's role; do not trust them for authorization decisions in the browser.

Signature is HMAC-SHA256 (HS256) with a server-side secret. Don't try to verify them client-side — you can't, and you shouldn't need to.

Logout and revocation

  • logout(input: { allDevices: false }) revokes the current session.
  • logout(input: { allDevices: true }) revokes every active session for this account.
  • revokeSession(input: { sessionId: "…" }) is the admin variant — revoke someone else's session by ID, used to force-logout users after a security incident.

Once a session is revoked, its JTI is dropped from the refresh-token store. Subsequent refresh attempts return SESSION_REVOKED. Existing access tokens stay structurally valid until their exp — there's no per-request session check on the access token path, so a stolen access token can in principle continue working until it expires. Keep your access TTL short.

What's next

  • API Keys — for server-to-server auth.
  • RequestContext — what the backend sees once a JWT is decoded.
  • Rate Limits — JWT-session limits are separate from API-key limits.

Build the foundation once. Expand without limits.

BetterSuite is built for teams who see on-demand as a business — not a feature.