Verifying an end user
When someone uses your application, your backend often needs to know who they are. Verifying an end user is how your backend checks each incoming request: is the person who sent it signed in, and if so, which person is it? This page explains what the session cookie contains, how a native app presents the same session token, and the two ways your backend verifies that token. It also covers what the router adds to a forwarded request, how the realm's signing keys rotate, and what a sign-out or a revocation does.
An application that declares the accounts service has an end-user realm for each environment it has: production's alone on one environment, and a development realm too once development is turned on. A person who signs in on the application's hostname has a session in that realm (Manage end users). A development realm has a size limit, and a sign-in that would exceed it is refused development_realm_full; Manage end users states the limit. Sign-in, sessions, and tokens compares the end-user session with the platform's other credentials.
The session cookie contains a signed token
A signed-in end user has one cookie on the application's own hostname, named __Host-turnzero_cloud_realm. The cookie is host-only, HttpOnly, Secure, and SameSite=Lax, with the path /. Its value is a session token in three parts joined by dots: the prefix turnzero_cloud_usr_1, a payload, and a signature. A session opened before this token form existed has an older opaque value under the same cookie name. The router accepts that value too and has the accounts service sign a token for it, so the session continues and the cookie is replaced.
A cookie your application sets on its own hostname is also host-only. The router removes any Domain attribute from a Set-Cookie your application sends, before it reaches the browser. It does this because every application is served under one domain that browsers do not yet treat as a public suffix. The application's log records each removal with the cookie's name and its Domain value, never the cookie's value.
The payload is the base64url encoding of a JSON object with seven members:
kid, the identifier of the realm key that signed the token;realm, the realm's identifier;sub, the end user's opaque identifier;jti, a random session identifier;iatandexp, the token's issue and expiry times in seconds; andgen, the user's generation number at the time of signing.revoke_end_useradvances it, which ends every session signed before.
The signature is an Ed25519 signature over the prefix and the payload, made with the realm's private key. Every realm has its own key pair. The platform keeps the private key and never gives it out. The public keys are published, as the next section describes.
A token is about 400 bytes. It has no algorithm header and accepts no other algorithm, because the prefix identifies the version and the algorithm.
A session token lasts one hour; the session lasts longer
The session token expires one hour after it was signed, or at the session's own expiry if that comes first. The platform currently sets this to one hour. The session itself lasts the realm's session length, 30 days by default, and the platform keeps a record of it. The token is a signed statement about that record.
When a request arrives with an expired token, the router asks the accounts service for a new token for the same session. The accounts service checks the session record and the user's standing, then signs a new token. The router sets the new token as the cookie on its response and forwards the request with it in place of the old one. The application sees only a valid token, and none of its code takes part.
The hour limits only local verification: the router's, and the client's with the realm's public keys. The verification route and the realm's own routes, /__account/me among them, check the session record instead. They therefore accept a token past its hour while its session is live. They still check the token against the realm's keys first: a token whose key was deleted is refused session_ended even while its session is live.
An ended session, a suspended user, or a revoked key gets no new token. What happens to the request then depends on the audience:
- On an
invitedorworkforceaudience, a navigation request gets the sign-in redirect, as an unsigned request does. Any other request, including a fetch from a page script, receives 401authentication_required. - On a
publicaudience, the request is forwarded with the header described below set toinvalid. - A token presented as a bearer is refused 401 on every audience, as the section on native apps states.
The two ways to verify
The Account package's verification client, makeVerifyClient, is imported from the npm package @turnzero/account. It is published compiled in the library entry's lib/ folder, and the entry contains no source and no tests. The client has two paths, and which it takes depends on what your application gives it when it is created.
Local verification with the realm's public keys
Where the environment has a realm, every deploy, promote, and platform redeploy supplies the setting TURNZERO_CLOUD_REALM_KEYS to the container, beside TURNZERO_CLOUD_API. Its value is a JSON object with three members:
realm, the environment's realm identifier;version, the time of the realm's last key change, or 0; andkeys, an array of{ "kid", "public_key" }objects, eachpublic_keya raw 32-byte Ed25519 public key in base64url.
The array is empty before the realm's first sign-in, because the realm's first key is created at that sign-in.
A client created with realm (the environment's realm) and realmKeys (the setting's JSON string, as the environment provides it) verifies a token without a network call. It checks, in order:
- the prefix;
- the payload's
realmagainst the configured realm, never against the token's own claim; - that the set contains a key with the payload's
kidthat is not revoked; and - the signature and the expiry.
If the payload's kid identifies a key the client does not have, the client fetches the realm's current key set and verifies again. The route is the platform's public GET /accounts/v0/realms/<realm>/keys at the TURNZERO_CLOUD_API origin. It needs no credential and returns { "realm", "version", "keys" } for the client's configured realm. The client caches a fetched set for one hour and a failed fetch for ten seconds, and makes at most one fetch at a time per realm.
Local verification proves that the platform signed the token and that the token has not expired. It does not detect a sign-out, a revoke_end_user, or a revoked realm key, because none of those changes the token. The router's header reports them.
The router's session header
The router sets the header x-turnzero-cloud-session on every forwarded request that includes the session cookie or the token as a bearer, once it has the realm's keys. A request without either gets no header. The router deletes any header of that name the client sent before it sets its own, so the value your application reads is always the router's. The header has two forms:
valid; realm=<realm>; user=<the end user's identifier>; exp=<the token's expiry in seconds>; t=<the first 16 characters of the token's SHA-256 in base64url>;invalid; reason=<a short name>. Examples areuser_revokedafterrevoke_end_user,key_revokedafter a key rotation for a compromise, andkey_unknownfor a token whose key the router still does not have after its extra sync (below).
The t member ties the header to one token, so the client can check that the header is about the token in the request. The header grants nothing by itself. The client accepts a user only on a token it verified locally, and then checks the header, passed on each call as sessionHeader:
- A
validheader whosetmatches the token and whoserealmandusermatch the token's claims accepts the user. - With an
invalidheader, the client refuses the user with the header's reason. - A missing, malformed, or mismatched header is treated as unknown, and the client falls back to the verification route described next.
The client caches a refusal for ten seconds, keyed by the token's hash.
The router computes the header from the realm's public keys and a revocation list, both copied from the platform. It copies a realm's keys and list when it first sees the realm, then every 30 seconds while it serves the realm. On an error it keeps the keys and list it already has.
If a token's kid identifies a key the router does not have, the router copies the realm once more and verifies again before it responds. A person who signs in just after a key rotation or revoke_realm_keys is therefore accepted on the first request.
The router makes this extra copy at most once per realm in ten seconds, so requests with made-up key ids cannot make it copy over and over. A second unknown key id within those ten seconds gets the header invalid; reason=key_unknown. So does a token from a new sign-in that arrives in that window, until the ten seconds pass or the 30-second copy runs.
A native app presents the token as a bearer
A native app has the same token as a browser does, obtained at the realm's token endpoint (Sign in from a native app). It sends the token in the Authorization header as Bearer turnzero_cloud_usr_1..... The router treats a bearer of that form as it treats the cookie, verifies it the same way, and sets the same session header on the forwarded request. The bearer takes precedence over a cookie sent beside it. An Authorization header with any other value reaches your application untouched, so your own bearer tokens are unaffected.
A bearer the router cannot verify is refused before your application sees it, on every audience, the public one included. An expired token, a session that ended, a revoked user, and another realm's token are each refused 401 authentication_required with the header WWW-Authenticate: Bearer error="invalid_token". The router never renews a bearer; the app refreshes at the token endpoint before the hour passes. Each refused bearer counts once against the sender's client address under the per-address limit (Egress firewall and request limits), and bad bearers past that limit are refused rate_capped.
The verification route
A client created without keys uses only the verification route. It sends POST /accounts/v0/verify to the platform under the application's credential TURNZERO_CLOUD_TOKEN, with the cookie's value in the body. It caches a successful response for the shortest of three times: its configured interval (60 seconds by default), the route's cache_until, and the session's expiry. An application that verifies through the route needs no key set and can leave TURNZERO_CLOUD_REALM_KEYS unread.
A local run presents the development credential. On an application with one environment, which has no development realm, the route checks that credential's sessions against production's realm, the application's only list of end users. It checks a session someone already has, and starts none. With development turned on, each credential reaches its own environment's realm alone.
The route checks a token against the realm's keys before it checks the live session:
- A token signed by a key that
revoke_realm_keysrevoked is refusedsession_revoked. - A token whose signature fails, or whose key the realm no longer has, is refused
session_ended.
A session of another realm is refused session_other_realm, whether it is live, signed out, or expired. The response tells your backend nothing else about that session.
Every verification through the route counts as a metered backend action. A request the router verified locally counts once, as the forwarded request, and not as a verification.
The token contains the user's identifier, the realm, and the generation number. It contains none of the user's addresses, sign-in methods, or standing. A backend that needs those gets them from the verification route's response.
So a backend that verifies locally and needs a user's verified email keeps a second client, built without the keys, and calls it only for that read. Each such call is a metered backend action. No test checks this sample.
A user who signed in only with a work account has the address the company's tenant asserts, marked source: "tenant". The sample's verifiedEmail reads verified addresses alone. So it answers that address only where the tenant's token carries xms_edov as true and no other account already holds it verified, and null otherwise. A route that accepts the tenant's word reads the address marked source: "tenant" instead.
import { makeVerifyClient } from '@turnzero/account';
const settings = { baseUrl: process.env.TURNZERO_CLOUD_API, credential: process.env.TURNZERO_CLOUD_TOKEN };
const local = makeVerifyClient({ ...settings, realmKeys: process.env.TURNZERO_CLOUD_REALM_KEYS });
const route = makeVerifyClient(settings);
async function verifiedEmail(session, sessionHeader) {
const verdict = await local.verifySession(session, { sessionHeader });
if (!('user' in verdict)) return null;
const full = await route.verifySession(session);
if (!('user' in full)) return null;
return full.user.addresses.find((address) => address.verified)?.email ?? null;
}
Signing-key rotation and revocation
A realm's keys rotate without any step from you. At a sign-in or a token renewal, if the realm's newest key is older than 180 days, the platform creates a new key and retires the previous one. A retired key still verifies the tokens it signed for one hour plus one day, and is then deleted. The verification client fetches the new key the first time a token refers to it, and the next deploy or promote supplies the current set. Nothing in your application changes.
revoke_realm_keys is the management action for a suspected compromise of a realm's signing key. It takes application and environment. It is a reversible action, so it needs no browser approval. It revokes every signing key of the realm, and the accounts service creates the realm's next key at its next sign-in.
A revoked key verifies nothing and signs no new token. Every session it signed ends at its next request, and the router's header becomes invalid; reason=key_revoked. A client that still has the old key set refuses the token through the header. A client that verifies through the route meets session_revoked there. Each user signs in again.
What a sign-out or a revocation does
These actions end sessions:
- the user's sign-out,
POST /__account/signouton the application's hostname; - the user's ending of one of their own sessions, the presenting one included,
POST /__account/sessions/<id>/revokeon the application's hostname; - a native app's revocation call,
POST /__account/oauth/revoke; revoke_end_user;- a sign-in that would open a user's 101st live session, which ends the user's oldest;
- deleting an end user; and
- deleting the realm, with its environment or its application.
Each writes an entry on the realm's revocation list together with its own change. The routers pick up the entry within their 30-second copy interval. Within a minute of the action, the next request with the ended session reaches your application with the header set to invalid, and the client refuses it. The platform also does not renew the token, so an ended session does not outlast its token.
reinstate_end_user restores a revoked user's standing and allows a new sign-in. The ended sessions stay ended.
The revocation list keeps one hour of endings, the token's life. After that hour every token of an ended session has expired, and the platform does not issue a new one. During an outage of the platform's management services, the routers keep the keys and list they have. A revocation made during the outage reaches them when those services return.
What stays the same on either path
Which verification path your application takes changes none of the following:
- The cookie's name and options, and the realm's session length, are as stated above.
- The sign-in pages under
/__account/on each environment's hostname, and the sign-in methods a realm offers (configure_realm), are the same. With development turned on, the development hostname shows the sign-in pages under/__account/from the moment the development realm exists, before any deploy. On one environment it returns 404 there, namingcreate_environment. - The router reads the audience the manifest declares, with its
session_free_paths, and applies the audience rule before it forwards a request. - The actions
issue_invitation,list_end_users,revoke_end_user,reinstate_end_user, anddelete_end_usermanage a realm's users. Each takes an optionalenvironmentthat defaults toproduction. - The verification route still works for a client that passes it no keys, as described above.
verifyOwner, the client's owner check, reads the owning account under a bearer and compares it with the configured owner account. It reads no session token.
Current values
| Value | Current setting |
|---|---|
| The token life | one hour |
| The routers' copy interval for keys and revocations | 30 seconds |
| The router's wait between two extra copies for an unknown key id, per realm | 10 seconds |
| The key rotation interval | 180 days |
| The client's refusal cache | 10 seconds |
| The client's key-set cache, and its cache of a failed fetch | one hour, and 10 seconds |
| The verification route's client cache, by default | 60 seconds |
| The realm's session length | 30 days by default; configure_realm's session_days sets 1 to 30 |
The platform sets the token life, the copy interval, and the rotation interval, and can change them without any change to your application. The session length is yours: configure_realm's session_days sets it from 1 to 30 days, 30 by default, for sessions opened after the call (Manage end users). The client's caches are constants of the Account package's client, and change only when your application takes a new package version. The route cache's interval is also the client's cacheSeconds option.
Adopting the local path
To use the local path, an application uses the Account package at version 0.9.0 or later. It passes the client the realm, the key-set string, and each request's header. The package's integration guide documents the three. An application that passes none of them keeps using the verification route.
Related
- Manage end users covers the realm's sign-in methods, invitations, revocation, and reinstatement.
- Sign-in, sessions, and tokens compares the end-user session with the platform's other credentials.
- Account is the package page for the verification client and the accounts service.
- Applications and environments explains the two hostnames and when the development realm's sign-in pages exist.
- Actions lists
revoke_realm_keys,revoke_end_user, and the other end-user actions with their payloads.