Backend Client
Learn how to connect other trusted machines to Jazz through your main backend, for machine-to-machine access.Your main backend is the one that runs createJazz: browsers and apps sync
through it, and its authenticate decides who each user is. A backend client is any other
trusted machine that reads and writes the same data, such as a CI job, a worker on another cloud,
a script or a partner's service. It connects to your main backend's /jazz like a browser does,
but proves who it is with its own machine-to-machine credential, not a user's session.
A backend client is one of two kinds:
- Trusted. It acts as the system, with your main backend's own authority: your permissions are not checked, and its writes carry the system identity. It can also act for any user. Use it for system work such as imports, migrations and scheduled jobs.
- Scoped. It acts as one user, with that user's permissions, exactly as if
authenticatehad named the user. Use it for an agent or service that should only touch one account's data.
Trust the machine in your main backend
Most platforms give their workloads an OIDC token: GitHub Actions, Vercel, Google Cloud, Azure and
Kubernetes all do. List each machine you trust in backendClients, naming it by the token's issuer,
audience and the claims only that machine has:
jazz.server.tsimport { createJazz, sqlite, trustWorkload } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const jazz = createJazz({app,permissions,store: sqlite({ path: "./data/jazz" }),authenticate: async (request) => /* your users' sessions */ null,backendClients: [// The nightly import job in this repo's production environment: a trusted machine.trustWorkload({issuer: "https://token.actions.githubusercontent.com",audience: "jazz:my-app",claims: { sub: "repo:acme/my-app:environment:production" },as: "backend",}),// A support agent that acts as its own user.trustWorkload({issuer: "https://token.actions.githubusercontent.com",audience: "jazz:my-app",claims: { sub: "repo:acme/support-agent:environment:production" },as: () => ({ userId: "support-agent", claims: { role: "agent" } }),}),],});
issueris where Jazz finds the token's signing keys, through its OpenID discovery document.audiencemust match the token'saud, so tokens minted for other services are refused.claimsmust all match exactly. Name the workload down to its environment: every workload of the same issuer gets tokens signed by the same keys, soclaimscan't be empty.asdecides the kind:"backend"for a trusted machine, or a function from the token's claims to a user ({ userId, claims? }, ornullto refuse) for a scoped one.
A token from a listed issuer that fails any check is refused. It never reaches authenticate.
Connect from the machine
On the machine, open a Jazz session against your main backend and pass a function that returns a fresh token. Jazz calls it for every request and socket, so short-lived tokens just work:
import-job.tsimport { createJazzClient } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";const session = await createJazzClient({app,permissions,serverUrl: "https://my-app.com/jazz",driver: { type: "memory" },credential: githubOidcToken,});const { client } = session.getSnapshot();await client!.db.insert(app.imports, { source: "nightly" }).wait({ tier: "global" });await client!.db.shutdown({ waitForSync: true }); // let queued writes reach the backendawait session.close();// GitHub Actions hands out OIDC tokens to jobs with `permissions: id-token: write`.async function githubOidcToken() {const url = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=jazz:my-app`;const response = await fetch(url, {headers: {Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,},});return (await response.json()).value as string;}
session.close() on its own discards writes that haven't reached the backend yet, and so does
exiting the process. Before a script ends, await db.shutdown({ waitForSync: true }). Each token
needs at least 60 seconds of validity left when Jazz uses it; one closer to expiry is refused. Each
identity can hold at most 4 sync sockets at a time for now, so keep to four workers per trusted
identity.
While your main backend is unreachable, createJazzClient waits without logging anything, so give a job
its own timeout.
The session opens as whatever your main backend trusts the token as: with backend authority for a
trusted machine, or signed in as its user for a scoped one. Your main backend's API routes see the
same identity, so jazz.forReqUser(request) and jazz.accountFor(request) work for requests that
carry the token as Authorization: Bearer ….
A scoped client's user gets an account when the client first signs in, like any client sign-in.
Before that, accountFor and forReqUser in your API routes find no account for that user; call
jazz.createAccountFor(userId) in your main backend if a route needs it earlier. A trusted client
uses the system identity and needs no user account.
Act for a user
A trusted client can work as one of your users with client.forUser(userId). It returns the
database as that user, just like jazz.forReqUser(request) in your main backend: writes carry the
user's identity and are checked against that user's permissions. userId is the id your main
backend's authenticate names the user by.
digest-job.tsimport { isNoJazzAccountError } from "jazz-tools/backend";const { client } = session.getSnapshot();try {const db = await client!.forUser("user_123", { claims: { org: "acme" } });await db.insert(app.notifications, { text: "Your weekly digest" }).wait({ tier: "global" });} catch (error) {if (!isNoJazzAccountError(error)) throw error;// This user hasn't signed in yet, so they have no account to act as.}
forUsernever creates an account. A user with no account yet rejects with aNoJazzAccountError(code: "no_account"). They get one when they first sign in, or when your main backend callsjazz.createAccountFor(userId).- Your
authenticateisn't called, because there is no request from the user. Your declarative permissions see exactly theclaimsyou pass as the user's claims, and{}when you pass none. - If your main backend refuses the identity, for example because it was revoked,
forUserrejects with an error whosestatusis 403. - Only a trusted client has
forUser. On a scoped client it throws, because that client already acts as its user. In your main backend itself, usejazz.forReqUser.
Machines without an OIDC token
Some machines can't get a platform OIDC token, such as a developer's laptop, an AWS workload signing
with SigV4, or a Cloudflare Worker. Give those your app's own credential instead, such as an API key
or a token your backend issues, and resolve it to a user in authenticate, like any other sign-in.
They then act as that user, with that user's permissions.