TypeScript SDK
@zynth/auth-client is the TypeScript/Node SDK for Zynth Auth. It gives you the hardened
token verifier we use ourselves, a client for building governed AI agents, and the
client half of sender-constrained (DPoP) tokens. It runs on Node 20+ and on edge runtimes
(WebCrypto only — no native bindings).
Distribution: the package currently ships with the platform repo (
packages/zynth-auth-client-ts). npm publication lands with the commercial release.
Agents with zero standing secrets:
AgentSessionalso acceptsfederated: { tenant, getSubjectToken }instead of a credential, with ready-made providersgithubActionsIdToken()andkubernetesProjectedToken()— see Federated workload identity.
Verifying tokens
Verification is local and offline after the first key fetch — no round-trip per request:
import { createVerifier, ZynthAuthError } from "@zynth/auth-client";
const verifier = createVerifier({ baseUrl: "https://auth.example.com" });
const claims = await verifier.verify(token);
// claims.sub — the user id (or agent id)
// claims.tid — the tenant
// claims.actor — "agent" for AI-agent principals, absent for humans
The verifier pins the issuer and audience — the platform defaults are zynth-auth and
zynth-services, and both are overridable: createVerifier({ baseUrl, issuer, audience }).
If your API is a registered OAuth resource
(RFC 8707), pass audience: "<your resource identifier>" so only tokens minted for your
API verify — and never configure a verifier to accept both audiences. It also allowlists
RS256, selects the signing key by kid from a cached JWKS, and requires
type: "access" — so a refresh, MFA-challenge, or magic-link token can never satisfy an
API gate even though it carries a valid signature. Failures throw a ZynthAuthError with a
code you can branch on (token_expired, token_invalid, claims_invalid, token_missing).
The verifier validates access tokens only. id_tokens from the OIDC login flow are a
different document with a different issuer (the discovery issuer URL) and audience (your
client_id) — validate those with your OIDC client library, per
Log in with Zynth Auth.
Express
import { expressAuth } from "@zynth/auth-client";
app.use(expressAuth(verifier)); // 401s on failure; attaches req.zynth on success
app.get("/me", (req, res) => res.json({ user: req.zynth.sub }));
The adapter is structurally typed, so it works with any framework whose request/response
objects look like Express's. For anything else, call verifier.verify() directly.
Building a governed agent
AgentSession exchanges an agent credential for a short-lived token (refreshing
transparently, and collapsing concurrent refreshes) and calls MCP tools through the
tool gateway, where every call is individually authorized:
import { AgentSession, ZynthAgentError } from "@zynth/auth-client";
const agent = new AgentSession({
baseUrl: "https://auth.example.com",
credential: process.env.ZYNTH_AGENT_CREDENTIAL!,
});
const result = await agent.callTool("github", "search", { q: "zynth" });
Refusals are typed, so your agent can react to why it was stopped instead of parsing HTTP errors:
error.code | What happened |
|---|---|
approval_required | A human must approve this action; error.approvalRequestId is the request |
denied | Fail-closed governance refusal — risk ceiling, delegation scope, kill switch, or budget (error.message carries the reason) |
upstream_error | The tool server itself was unreachable or misbehaved |
tool_error | The tool ran and returned a JSON-RPC error (error.rpcError) |
credential_rejected | The agent credential was rotated, suspended, or is wrong |
Human-in-the-loop, as one awaited call
For supervised agents, pass awaitApproval and the SDK will
poll the approval request and retry once a human says yes — re-sending the exact same
arguments, which is what the single-use approval is bound to:
const result = await agent.callTool(
"github", "search", { q: "zynth" },
{ awaitApproval: { timeoutSeconds: 300, pollIntervalSeconds: 3 } },
);
If the human denies it you get approval_denied; if nobody decides in time,
approval_timeout. Without awaitApproval, the first approval_required throw is
returned to you immediately so you can queue the work yourself.
Sender-constrained tokens (DPoP)
Wrap any fetch to attach a fresh proof per request. The keypair is generated in-process and
is non-extractable; when you present a DPoP-bound token the proof is also bound to it (ath),
so a stolen token alone is useless:
import { createDpopFetch, createDpopKeypair } from "@zynth/auth-client";
const dpopFetch = createDpopFetch(await createDpopKeypair());
const agent = new AgentSession({ baseUrl, credential, fetch: dpopFetch });
Embedded security modules
Four helpers that protect your own surface. They are ordinary middleware and utilities —
nothing here calls Zynth Auth except reportAnomaly.
Rate limiting
import { rateLimit } from "@zynth/auth-client";
const limiter = rateLimit({ limit: 100, windowMs: 60_000 });
app.use(async (req, res, next) => {
const { allowed, remaining, resetAt } = await limiter.check(req.ip);
res.setHeader("RateLimit-Remaining", String(remaining));
if (!allowed) return res.status(429).json({ detail: "Too many requests" });
next();
});
Fixed-window, with a pluggable store. The default is in-process, so behind a load balancer
each instance keeps its own count and the effective cap multiplies by instance count —
implement RateLimitStore against Redis when that matters:
interface RateLimitStore {
increment(key: string, windowMs: number, now: number): Promise<{ count: number; resetAt: number }>;
}
It fails open. If the store is unreachable, requests are allowed. A limiter that 500s when its cache blips converts a minor outage into a total one. This is deliberately the opposite of token verification, which fails closed.
This is your limiter, running in your process. It is not a Zynth Auth platform control — a limit the caller can decline to enforce could never be one.
Sanitizing input
import { sanitize } from "@zynth/auth-client";
const clean = sanitize.text(userInput, { maxLength: 200 });
const forHtml = sanitize.text(userInput, { escape: true });
sanitize.text strips control characters, trims, and bounds length. Also available
individually: escapeHtml, stripControlChars, boundLength.
Two things worth knowing:
- This is not an HTML sanitizer.
escapeHtmlis correct for a text node or a quoted attribute. It is not sufficient inside<script>, insidestyle, or in a URL position. To accept real markup, use an allowlist-parser sanitizer. escapedefaults to false. Escape when you render, not when you store — escaping on the way in double-encodes as soon as the value passes through a second layer.
Truncation counts code points, so it can never split a surrogate pair and leave a lone half (invalid UTF-8, which some databases and JSON encoders reject outright).
CSRF protection
import { csrf } from "@zynth/auth-client";
app.use(csrf({ cookie: { secure: true, sameSite: "lax" } }));
Issues a token cookie on safe requests and requires a matching x-csrf-token header on
state-changing ones — 403 on mismatch, using a constant-time compare.
Read this before relying on it:
- The cookie is not
httpOnly— your front end has to read it to set the header. That is inherent to double-submit, and it means an XSS bug defeats this entirely. SameSiteis the stronger control. Treat this as defence in depth behind it, not instead of it.- A subdomain you do not control can write cookies for the parent domain and forge the cookie half. If untrusted subdomains exist, this pattern is not enough on its own.
Reporting anomalies
Tell the platform when your surface sees something suspicious. The signal joins the same event stream as Zynth Auth's own events, so the Command Center correlates them together:
import { createAnomalyReporter } from "@zynth/auth-client";
const reporter = createAnomalyReporter({ baseUrl, token: accessToken });
await reporter.report({
kind: "rate_limit_exceeded",
severity: "high",
subject: hashedUserRef,
detail: "burst from a single source",
});
kind is one of rate_limit_exceeded, csrf_rejected, input_rejected,
auth_failure_burst, suspicious_request, custom.
It never throws. report() returns a boolean; transport failures, timeouts and non-2xx
responses all resolve false. Pass onError to observe them. Adding telemetry must not make
your request path less reliable than it was without it.
Two things the platform does with what you send, which are worth understanding:
- Attribution comes from your token, not your payload. There is no tenant field to set, and you cannot report on behalf of anyone else.
severityis recorded as a claim, not adopted as fact. It is stored asclaimed_severity; the platform's own severity for the event stayswarn. Detection is volume-based — many reports from one tenant is a signal we trust, one report labelled critical is not. This is what keeps a noisy or compromised integrator from steering someone else's alerting.
Do not put raw PII in subject or detail — both are stored. Send a hashed or opaque
reference.