Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

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: AgentSession also accepts federated: { tenant, getSubjectToken } instead of a credential, with ready-made providers githubActionsIdToken() and kubernetesProjectedToken() — 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.codeWhat happened
approval_requiredA human must approve this action; error.approvalRequestId is the request
deniedFail-closed governance refusal — risk ceiling, delegation scope, kill switch, or budget (error.message carries the reason)
upstream_errorThe tool server itself was unreachable or misbehaved
tool_errorThe tool ran and returned a JSON-RPC error (error.rpcError)
credential_rejectedThe 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. escapeHtml is correct for a text node or a quoted attribute. It is not sufficient inside <script>, inside style, or in a URL position. To accept real markup, use an allowlist-parser sanitizer.
  • escape defaults 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.
  • SameSite is 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.
  • severity is recorded as a claim, not adopted as fact. It is stored as claimed_severity; the platform's own severity for the event stays warn. 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.