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.
| Flow | Mutation | Notes |
|---|---|---|
| Phone + OTP | startPhoneVerification → verifyOtp | The marketplace-app default. |
| Email + OTP | startEmailLogin → verifyEmailLogin | Common for vendor / admin apps. |
| Email + password | loginWithPassword | Used by the dashboards. |
| Passkey | WebAuthn ceremony → completePasskeyAuthentication | Recommended for the Owner Dashboard. |
| Refresh | refreshToken | Renews 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
| Token | Default TTL | Source |
|---|---|---|
| Access token | 15 minutes (access_token_ttl_seconds = 900) | IdentityCacheTtlConfig |
| Refresh token | 7 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:
- Call
requestStepUp(input: { password: "…" }). The server verifies the password against the session's account. - The response is
{ token, expiresAt }. The token is a JWT with audience"step-up"and a TTL of 5 minutes (ELEVATION_TTL_SECS = 300). - Attach the token to the next mutation as the
X-Elevationheader. - The auth middleware verifies signature + audience + tenant/subject match against the bearer JWT, then sets an internal
x-elevation-jtifor the resolver. - 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:
| Claim | What it is |
|---|---|
sub | Account ID (UUID) |
sid | Session ID |
tid | Tenant ID |
kid | Signing key id (currently always "default") |
app | App context, snake_case — taxi, shop, parking, service, super, platform_admin, tenant_admin, partner_admin |
role | Role, snake_case — tenant_owner, tenant_admin, platform_admin, partner_admin, operator, driver, merchant, service_provider, park_spot_provider, customer |
pid | Partnership ID, only present on partner-staff sessions |
exp | Expiry, Unix timestamp |
iss / aud | Currently "any" / "any" and not validated; reserved for future multi-issuer setups |
jti | Unique 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.