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

  1. You create a signing key for the project, once, and keep it on your backend, for example as NOLAG_SIGNING_KEY.
  2. 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.
  3. When a signed-in user opens your app, your backend signs a JWT: header kid is the signing key id, claim sub is the actor's key id, claim exp is a few minutes ahead.
  4. 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:

FieldRequirement
Header algExactly HS256. Anything else is refused.
Header kidThe signing key id, sk_live_<12 hex> (or sk_sandbox_<12 hex>). The key must exist and be active.
Claim subThe 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 expRequired. 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 a reauth frame. Nothing reconnects and subscriptions are untouched. The new token must name the same actor.
  • It falls back to reconnecting. If the reauth is 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 your connect handler, as in the Quick Start.
  • An expired connection is closed. If a connection outlives its token (your endpoint was unreachable, say), kraken sends a disconnect frame with reason token_expired and closes the socket with code 4003 at 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 4001 and reason token_revoked at 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

CredentialLives onLifetimeUse for
Actor token, at_live_...Your servers and devicesUntil the actor is disabled or expiresBackend services, devices, server-side SDKs
Signing key, sk_live_...Your backend onlyUntil disabledMinting client tokens
Client token (JWT)Browser or mobile appOne hour at mostUntrusted clients
Static token in auth.jsonYour servers and devicesUntil removed from the filekraken's static auth backend

Next steps