4 min readResonate HQJust published

Token authentication and prefix authorization in TypeScript on Resonate

How the Resonate TypeScript SDK enforces server-side authentication and per-client promise-ID isolation through two constructor options.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

A Resonate server exposed beyond a single trusted process needs two things the SDK does not provide for free: a way to reject unauthenticated clients, and a way to stop authenticated clients from reading or writing each other's promises. The Resonate constructor in @resonatehq/sdk takes a token option that attaches a Bearer JWT to every server request, and a prefix option that namespaces every generated promise ID — together they cover authentication and per-client authorization without any application-level code. This example repo demonstrates both modes against a resonate dev server started with --auth-publickey.

The shape of the solution

import {Resonate, type Context} from '@resonatehq/sdk'
 
function* helloAuth(ctx: Context, greeting: string) {
  const result = yield* ctx.run((ctx: Context) => `${greeting} world!`)
  return result
}
 
// A Resonate instance that connects to a server deployed at localhost:8001,
// Assumes the generated JWT is stored in the env variable MY_TOKEN
// and the token has a custom claim {"prefix": "worker-1"}
const resonateAuthenticated = new Resonate({url: "http://localhost:8001", token: process.env.MY_TOKEN, prefix: "worker-1"})
 
 
const workflow = resonateAuthenticated.register("workflow", helloAuth)
console.log(await workflow.run("workflow.id", "hello"))
console.log(await workflow.rpc("workflow.rpc.id", "hello rpc"))
resonateAuthenticated.stop()
// from example-token-auth-ts/prefix-authz/index.ts:1-17

The workflow body is intentionally trivial — the load-bearing surface is the Resonate constructor. The token option carries an RS256-signed JWT; the prefix option scopes every promise this instance creates.

The durable primitives

  • new Resonate({ token }) — attaches the JWT to every outgoing server request; unauthenticated clients are rejected before any promise is created. token-auth/index.ts:10, prefix-authz/index.ts:11.
  • new Resonate({ prefix }) — prepends <prefix>: to every workflow ID generated through run, rpc, and each tick of schedule. prefix-authz/index.ts:11. The SDK assigns the prefix in resonate-sdk-ts/src/resonate.ts:133 as this.idPrefix = resolvedPrefix ? `${resolvedPrefix}:` : "".
  • resonate.register("workflow", helloAuth) — registers the generator function so it can be invoked by name; not auth-specific, but required to call .run / .rpc. token-auth/index.ts:13, prefix-authz/index.ts:14.
  • workflow.run(id, ...args) — durable execution of the registered workflow, routed through the server; runs in-process when this client claims the task. The final promise ID is <prefix>:<id> when a prefix is set, e.g. worker-1:workflow.id. prefix-authz/index.ts:15, prefix-authz/README.md:56-58.
  • workflow.rpc(id, ...args) — Remote Function Invocation (RFI): durable execution routed to a registered worker. Same prefix rule applies. token-auth/index.ts:15, prefix-authz/index.ts:16.
  • ctx.run(fn) — inside the workflow, an alias for the SDK's lfc (local function call) primitive, which records the step as a durable checkpoint. resonate-sdk-ts/src/context.ts:207-209, 276.

What the SDK handles vs. what you write

What the SDK handles: token attachment, env-var fallback, ID prefixing, and ResonateError translation.

  • When token is set on the constructor (or RESONATE_TOKEN is set in the environment), the HttpNetwork adapter resolves it and attaches it to every request head as the auth field — application code never touches the request envelope. resonate-sdk-ts/src/network/http.ts:65-66, 120-121.
  • When prefix is set on the constructor (or RESONATE_PREFIX is set in the environment), the Resonate instance stores this.idPrefix = "<prefix>:" and prepends it to every promise ID generated through run, rpc, and each tick of schedule (the per-tick promise-ID template, not the schedule's own name). resonate-sdk-ts/src/resonate.ts:133, 322, 396, 481. The same prefix is also applied to get lookups. resonate-sdk-ts/src/resonate.ts:493.
  • When the server rejects a request, the SDK surfaces it as a ResonateErrorThe request is unauthorized for a missing or bad signature, The request is forbidden for a valid token whose prefix claim does not authorize the requested promise ID. token-auth/index.ts:19, prefix-authz/index.ts:21.

What you write: the keys, the JWT, the server flag, and the choice of prefix.

  • Generate an RSA keypair with openssl genrsa / openssl rsa -pubout. README.md:32-39.
  • Mint a JWT. The payload differs by example: '{"prefix":""}' for token-auth (empty claim grants access to all promises) and '{"prefix":"worker-1"}' for prefix-authz (claim scopes access to promise IDs starting with worker-1:). Omitting the claim makes the JWT invalid. token-auth/README.md:31-39, 71-75, prefix-authz/README.md:31-41, README.md:50-54.
  • Start the server with resonate dev --auth-publickey public_key.pem. README.md:56-60. Without --auth-publickey the server does not enforce JWTs.
  • Pick a prefix policy. Either set prefix on the Resonate instance (every ID is implicitly namespaced — prefix-authz/index.ts:11) or write fully-qualified IDs at the call site (workflow.run("worker-1:workflow.id", ...)prefix-authz/README.md:60-72).

Failure modes covered

  • No token attached, server is enforcing auth. Construct a Resonate instance with no token and no RESONATE_TOKEN env var: the first request fails and the worker crashes with ResonateError: The request is unauthorized. token-auth/README.md:55-58, token-auth/index.ts:19.
  • Token present but signed by a different private key. The server rejects the signature on every request — same unauthorized outcome. The bad token never gets to act on a promise. token-auth/README.md:55-58.
  • Token valid but missing the prefix claim. The server treats the JWT as invalid; the SDK surfaces unauthorized. token-auth/README.md:71-75.
  • Token valid with prefix worker-1, but the Resonate instance was configured with a different prefix or the call site uses a non-prefixed ID. The server returns 403; the SDK surfaces ResonateError: The request is forbidden. prefix-authz/index.ts:20-22.
  • Two clients trying to read each other's state. Each client only holds a JWT for its own prefix. Promise IDs created by client A are tenantA:foo; client B's worker.run("foo", ...) resolves to tenantB:foo and never collides. Isolation is enforced by the server against the JWT's prefix claim, not by application code. README.md:17-21, prefix-authz/README.md:34-41.

When to reach for this pattern

  • If you expose a Resonate server beyond a single trusted process and need to reject unauthenticated clients at the network boundary — use token + --auth-publickey.
  • If every authenticated client is equally trusted and should see every promise, use the token-auth shape: pass token and leave prefix empty (or use a JWT with {"prefix":""}).
  • If you need multi-tenant isolation, per-worker isolation, or any case where one client must not read or write another client's promises, use the prefix-authz shape: each client gets a JWT whose prefix claim matches the namespace it is allowed to touch, and the Resonate instance is constructed with the same prefix.
  • If you are deploying workers as services where the token lifecycle is managed by the platform, use RESONATE_TOKEN / RESONATE_PREFIX env vars instead of constructor arguments — the SDK reads them as fallbacks. token-auth/README.md:62-69, prefix-authz/README.md:88-97.
  • If you need richer claim validation, revocation, or rotation than a static RSA keypair, treat this example as the SDK-side shape and replace jwt-cli with a real identity provider; the Resonate side does not change. README.md:13-15.

Sources