Client Tokens
An actor's access token is long-lived. That is right for servers and devices you control, and wrong for a browser: anyone who opens the developer tools can copy it and keep using it until you disable the actor.
A client token fixes that. Your backend signs a short-lived JWT that names an actor, using a project signing key, and hands it to the browser. The browser connects with the JWT. The permissions are still the actor's, resolved by @nolag/core on every connect, and a leaked client token stops working when it expires, at most an hour later.
Client tokens are a feature of @nolag/core. kraken passes every token to its auth backend unread; it is core that verifies the JWT. The static auth.json backend does not accept JWTs. If you write your own HTTP auth service, it can verify JWTs its own way and return auth_expires_at so kraken enforces the expiry.
How it works
- You create a signing key for the project, once, and keep it on your backend, for example as
NOLAG_SIGNING_KEY. - Each of your users maps to a core actor. You store the actor's public key id (
at_live_...) against your user record. The browser flow never needs the actor's access token itself. - When a signed-in user opens your app, your backend signs a JWT: header
kidis the signing key id, claimsubis the actor's key id, claimexpis a few minutes ahead. - The browser connects with the JWT. Core checks the signature and the claims, checks that the actor belongs to the same project as the signing key, and returns the actor's grants together with the token's expiry. kraken applies the grants, and closes the connection when the expiry passes unless the client has refreshed the token in the meantime.
No NoLag SDK ships a function that mints client tokens. Signing happens on your backend, with any JWT library.
Signing keys
A signing key looks like an actor token:
sk_live_<12 hex characters>.<43 base64url characters>
The part before the dot is the key id, which goes in the JWT header as kid. The part after the dot is the HS256 secret. Use it exactly as it is, as a string: do not base64-decode it.
Creating one. With the example host, signing keys are created by importing a project document that lists them:
{
"signingKeys": [{ "ref": "browser", "name": "Browser client tokens" }]
}
The import response carries each new key once, as { "ref": "browser", "keyId": "sk_live_...", "signingKey": "sk_live_....<secret>" }. A host of your own can also create, disable and delete keys through core's SigningKeyFacade. The example host has no route for managing a single key.
SIGNING_KEY_ENCRYPTION_KEY. HS256 verification needs the original secret, so core stores signing key secrets encrypted (AES-256-GCM) rather than hashed. The encryption key is 32 random bytes, base64-encoded, passed to core as signingKeyEncryptionKey (the example host reads it from SIGNING_KEY_ENCRYPTION_KEY; the quickstart generates one into .env). To make one:
openssl rand -base64 32
Without it, core cannot create signing keys and refuses every client token. Lose it, or change it, and every existing signing key becomes unreadable.
Minting a client token
Mint a fresh token whenever your frontend asks for one, from an endpoint that only your signed-in users can reach, and look up the user's actor key id there:
import jwt from "jsonwebtoken";
const [kid, secret] = process.env.NOLAG_SIGNING_KEY.split(".");
export function mintClientToken(actorKeyId: string): string {
return jwt.sign({ sub: actorKeyId }, secret, {
algorithm: "HS256",
keyid: kid,
expiresIn: "15m",
});
}Token rules
These are what core v0.5.0 checks:
| Field | Requirement |
|---|---|
Header alg | Exactly HS256. Anything else is refused. |
Header kid | The signing key id, sk_live_<12 hex> (or sk_sandbox_<12 hex>). The key must exist and be active. |
Claim sub | The actor's key id, at_live_<12 hex> (or at_sandbox_), not its UUID. The actor must be active and in the same project as the signing key. |
Claim exp | Required. At most one hour ahead. 15 minutes is a sensible default. |
Core allows 60 seconds of clock skew on exp, in both directions. Every failed check produces the same refusal: the client's connect() fails with access_denied, and the reason appears only in core's log. One exception: a token that expired within the skew allowance passes core, and kraken then refuses it with token_expired.
Connecting from the browser
Pass a function instead of a token string. The JavaScript SDK calls it on every connect and reconnect, so each attempt uses a freshly minted token:
import { NoLag } from "@nolag/js-sdk";
const client = NoLag(
async () => {
const res = await fetch("/api/nolag-token");
const { token } = await res.json();
return token;
},
{ url: "wss://realtime.example.com/ws" },
);
await client.connect();
What the SDK then does with the token, as measured against kraken v0.9.0 and core v0.5.0:
- It refreshes before expiry, in-band. Thirty seconds before
exp(or five seconds after connecting, whichever is later) it calls your function again and sends the new token over the live connection in areauthframe. Nothing reconnects and subscriptions are untouched. The new token must name the same actor. - It falls back to reconnecting. If the
reauthis refused or not answered, the SDK closes the socket and reconnects with a new token. kraken does not restore subscriptions on that reconnect, so subscribe in yourconnecthandler, as in the Quick Start. - An expired connection is closed. If a connection outlives its token (your endpoint was unreachable, say), kraken sends a
disconnectframe with reasontoken_expiredand closes the socket with code4003at the client's next heartbeat. The SDK reconnects at once, calling your function for a new token.
Import from @nolag/js-sdk in the browser too: the package declares a browser export condition, so bundlers pick the browser build.
On React Native, use @nolag/react-native, which also rechecks the token whenever the app returns to the foreground. See React Native.
The Python and Go SDKs take a token string only. They can connect with a client token, but they cannot refresh it: when it expires, kraken closes the connection and their automatic reconnect retries the same expired token. Give server processes actor tokens instead.
Revoking access
- One user. Disable the user's actor. New client tokens naming it are refused at once (apart from a token kraken validated in the last 30 seconds, which it may still accept from cache). Live connections are closed with
4001and reasontoken_revokedat their next revalidation, within about ten minutes. Revalidation checks the actor, not the signing key. - A leaked token. It is only useful until its
exp, at most an hour. - A signing key. Disable it, and tokens signed with it stop verifying. Core caches a resolved key for 60 seconds, and clears that cache when the key is changed through the same host instance, so other instances can accept the key for up to a minute. Connections already open keep running until their token expires, and their refreshes then fail.
The example host can only import, export and delete whole projects. Disabling a single actor or signing key needs a host of your own that calls core's ActorTokenFacade and SigningKeyFacade.
Rotating a signing key
A project can have several active signing keys, so rotation needs no downtime: create a new key, deploy it to your backend, then disable the old one once nothing mints with it. This too needs a host of your own, since importing a document always creates a new project.
Which credential where
| Credential | Lives on | Lifetime | Use for |
|---|---|---|---|
Actor token, at_live_... | Your servers and devices | Until the actor is disabled or expires | Backend services, devices, server-side SDKs |
Signing key, sk_live_... | Your backend only | Until disabled | Minting client tokens |
| Client token (JWT) | Browser or mobile app | One hour at most | Untrusted clients |
Static token in auth.json | Your servers and devices | Until removed from the file | kraken's static auth backend |