@cipherstash/auth
v0.43.0
Published
Authentication bindings for CipherStash services.
Keywords
Readme
@cipherstash/auth
Authentication bindings for CipherStash services.
Authentication bindings for CipherStash services. Ships native Node.js bindings for the full surface, and a wasm build for server-side edge runtimes (Supabase Edge Functions, Cloudflare Workers).
Not for direct browser use. This package is intended for server-side environments — Node.js, Edge Functions, Workers, Bun, Deno. Embedding it directly in a browser bundle would leak the access key into client-side source. The
"browser": falsefield inpackage.jsonmakes bundlers like webpack and browserify refuse browser builds; for bundlers that don't honor that convention (esbuild, Vite), don't include@cipherstash/authin client-only chunks. A browser-safe shape that exposes only signed operations (no raw JWT or access key) is tracked as a separate piece of work.
Installation
npm install @cipherstash/authThe package exposes five entries:
| Entry | Use when | Loads | Surface |
|---|---|---|---|
| @cipherstash/auth | Node.js | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, AccessKeyStrategy, OidcFederationStrategy |
| @cipherstash/auth | SSR bundlers (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-.wasm shim from wasm-pack --target bundler | AccessKeyStrategy, OidcFederationStrategy |
| @cipherstash/auth/wasm | Explicit opt-in to the sibling-.wasm shim | Same as bundler entry above | AccessKeyStrategy, OidcFederationStrategy |
| @cipherstash/auth/wasm-inline | Supabase Edge Functions / Cloudflare Workers / Bun / Deno via npm: — runtimes that can't auto-bundle a sibling .wasm | Inline-bytes shim (wasm embedded as base64) | AccessKeyStrategy, OidcFederationStrategy |
| @cipherstash/auth/cookies | Any runtime with WHATWG Request/Headers (Edge, Workers, Bun, Deno, Node 18+, Next.js App Router) | Pure-JS helper | cookieStore(...) — builds a TokenStore from a Request + Headers pair |
| @cipherstash/auth/next | Next.js App Router / request-response server frameworks | Pure-JS adapter over OidcFederationStrategy | csFederationMiddleware, csFederate, csAuthHeader — federate-or-reuse + warmed-token handoff (details) |
The wasm bindings expose AccessKeyStrategy (static M2M keys) and OidcFederationStrategy (federating a third-party OIDC JWT — Clerk, Supabase, … — into a CTS service token). The interactive device-code flow and profile-store loading stay Node-only — they depend on filesystem and browser-launching APIs that can't be ported to wasm.
The wasm, wasm-inline, cookies, and next entries are ESM-only — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic import() rather than require(). Only the default @cipherstash/auth entry has a CJS (node) build.
Node.js usage — OAuth device-code flow
const { beginDeviceCodeFlow } = require("@cipherstash/auth");
const result = await beginDeviceCodeFlow(region, clientId);
// Show the user the code and URL
console.log(`Go to ${result.verificationUri} and enter code: ${result.userCode}`);
// Or open the browser automatically
result.openInBrowser();
// Wait for the user to authorize
const auth = await result.pollForToken();
console.log(`Token expires in ${auth.expiresIn} seconds`);The token is saved to ~/.cipherstash/auth.json automatically and is never exposed to JavaScript.
Edge usage — Supabase Edge Functions / Cloudflare Workers
Pair the wasm-inline entry with the cookies helper to back the strategy with an HTTP-only cookie. Every Edge invocation gets a fresh strategy, but the cookie keeps the issued service token alive across invocations — so only the first request pays the full round-trip to CTS:
// supabase/functions/get-token/index.ts
import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline";
import { cookieStore } from "@cipherstash/auth/cookies";
import { Encryption } from "@cipherstash/stack";
Deno.serve(async (req) => {
const responseHeaders = new Headers({ "content-type": "application/json" });
const created = AccessKeyStrategy.create(
Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"
Deno.env.get("CS_CLIENT_ACCESS_KEY")!,
{ store: cookieStore({ request: req, responseHeaders }) },
);
if (created.failure) {
return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders });
}
// Hand the strategy to a CipherStash SDK — e.g. `Encryption` from
// `@cipherstash/stack` — which acquires and refreshes CTS tokens internally,
// so your code never handles a raw bearer token. You don't call `getToken()`
// yourself. (Need the token itself? See "Working with tokens directly" at the
// end of this README.)
const encryption = new Encryption({ authStrategy: created.data });
// ... encrypt / decrypt with `encryption` ...
return Response.json({ ok: true }, { headers: responseHeaders });
});supabase/functions/get-token/deno.json:
{
"imports": {
"@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline",
"@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies"
}
}Nothing extra in supabase/config.toml — no static_files, no asset copying, no bundler plugins. The wasm-inline entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config.
In the recommended flow you never call getToken() — the SDK does, internally. If you have a lower-level need for the raw token, see Working with tokens directly. Factory and token failures are handled the same way; see Error handling.
For Cloudflare Workers the shape is identical; env access becomes env.CS_CLIENT_ACCESS_KEY instead of Deno.env.get(...).
Federating a third-party OIDC JWT — OidcFederationStrategy
When the end user is already signed in with a third-party OIDC provider (Clerk, Supabase, Auth0, …), OidcFederationStrategy exchanges their provider JWT for a CTS service token via /api/authorise — no access key needed:
import { OidcFederationStrategy } from "@cipherstash/auth/wasm-inline";
import { cookieStore } from "@cipherstash/auth/cookies";
import { Encryption } from "@cipherstash/stack";
Deno.serve(async (req) => {
const responseHeaders = new Headers({ "content-type": "application/json" });
const created = OidcFederationStrategy.create(
Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"
// Returns the *current* provider JWT — re-invoked on every re-federation.
() => getClerkSessionToken(req),
{ store: cookieStore({ request: req, responseHeaders }) },
);
if (created.failure) {
return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders });
}
// As above, pass the strategy to a CipherStash SDK rather than calling
// `getToken()` yourself — the SDK owns token acquisition and refresh.
const encryption = new Encryption({ authStrategy: created.data });
// ... encrypt / decrypt with `encryption` ...
return Response.json({ ok: true }, { headers: responseHeaders });
});/api/authorise issues no CTS refresh token, so when the cached CTS token expires OidcFederationStrategy re-federates — it calls getJwt again for a fresh provider JWT. Pass a getJwt that returns a live token each time (e.g. wrapping the provider SDK), not a value captured once. The same API is available on the Node-native entry: const { OidcFederationStrategy } = require("@cipherstash/auth").
Caching with cookieStore
cookieStore({ request, responseHeaders }) returns a TokenStore:
load()parses theCookie:header from the request, findscs_token(configurable vianame), base64url-decodes it, and returns the JSON the strategy stored last time.save(json)happens automatically after every successful refresh / initial auth —cookieStoreappends aSet-Cookieheader toresponseHeaderswith the JSON base64url-encoded as the value,HttpOnly,SameSite=Lax, andMax-Agederived from the token'sexpires_atminus a 30-second safety margin.
Available options:
| Option | Default | Notes |
|---|---|---|
| request | — required — | Incoming Request to read the cookie from |
| responseHeaders | — required — | Outgoing Headers to append Set-Cookie to |
| name | "cs_token" | Cookie name |
| domain | unset | Domain attribute (host-only by default) |
| path | "/" | Path attribute |
| secure | true | Set false only for localhost HTTP dev |
| httpOnly | true | Prevents JS access — keep this on |
| sameSite | "Lax" | "Strict" / "Lax" / "None" |
| expirySafetyMarginSeconds | 30 | Seconds subtracted from expires_at when computing Max-Age |
The base64url encoding skirts RFC 6265's cookie-value char range, which would otherwise reject the " characters present in raw JSON.
Rolling your own store
The store field accepts any { load, save }-shaped object — Redis, KV stores, an in-memory Map, anything you'd reach for:
const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey, {
store: {
async load() { return await redis.get("cs:token"); /* string | null */ },
async save(json: string) { await redis.set("cs:token", json); },
},
});Errors thrown inside load / save are caught and logged via console.warn — the strategy treats them as cache misses and falls back to fresh authentication.
Why the explicit sub-path
Bare @cipherstash/auth works in Node (resolves to native napi) and in wasm-aware bundlers (Vite/Webpack handle the sibling-.wasm import natively).
It does not work in Deno-resolving-npm: runtimes (Supabase Edge, Cloudflare Workers via npm:). Deno applies the node exports condition for npm: specifiers — it emulates Node for npm packages — which routes the bare import to the napi loader. That loader is a CJS module without statically-resolvable ESM named exports, so it errors at boot. There's no condition Deno applies for npm: packages that Node ESM doesn't, so we can't route the two apart in the exports map. The wasm-inline sub-path bypasses the conditional walk entirely.
Trade-off for inline: ~27% larger JS payload (~726KB vs ~572KB raw wasm + JS shim) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot.
Bundler users (Vite / Webpack / Next.js)
Bare import is the right shape — these bundlers understand the sibling-.wasm reference and emit it as an asset:
import { AccessKeyStrategy } from "@cipherstash/auth";If your bundler doesn't handle .wasm imports, fall back to @cipherstash/auth/wasm-inline. All three entries expose identical APIs.
API
Node — beginDeviceCodeFlow(region, clientId)
Starts the OAuth 2.0 Device Authorization flow. Returns a Promise<DeviceCodeResult>.
DeviceCodeResult
| Property / Method | Description |
|---|---|
| userCode | The code the user enters at the verification URI |
| verificationUri | The URL the user visits to authorize |
| verificationUriComplete | URL with the code pre-filled |
| expiresIn | Seconds until the device code expires |
| openInBrowser() | Opens the verification URI in the default browser |
| pollForToken() | Polls until the user completes authorization. Returns Promise<AuthResult> |
AuthResult
| Property | Description |
|---|---|
| expiresAt | Absolute epoch timestamp (seconds) when the token expires |
| expiresIn | Seconds until the token expires |
Edge — AccessKeyStrategy
| Method | Description |
|---|---|
| AccessKeyStrategy.create(workspaceCrn, accessKey, options?) | Build a strategy from a workspace CRN and access key (returns a Result). Region is derived from the CRN. Pass { store } to back it with a persistent cache. The strategy verifies every issued token's workspace claim against the CRN — mismatch surfaces as failure.type === "WORKSPACE_MISMATCH". |
| strategy.getToken() | Retrieve a valid token, refreshing as needed. Resolves to { data: TokenResult } or { failure }. |
TokenResult is { token, subject, workspaceId, issuer, services }.
options.store accepts any { load, save }-shaped object:
interface TokenStore {
load(): Promise<string | null | undefined>; // null = cache miss
save(json: string): Promise<void>;
}The strategy calls load on cold start (no in-memory token); if it returns a still-fresh JSON, the strategy reuses it. Otherwise it hits CTS for a fresh token and writes it back via save. Stale tokens trigger a refresh and the refreshed token is persisted.
Edge — cookieStore
Helper that returns a TokenStore backed by an HTTP-only cookie. Works in any runtime that exposes WHATWG Request / Headers. See the Caching with cookieStore section above for the option reference.
Error handling
Every fallible operation returns a @byteslice/result Result instead of throwing: { data } on success, { failure } on a domain error. Check result.failure — no try/catch needed:
const result = await strategy.getToken();
if (result.failure) {
console.error(result.failure.type); // e.g. "EXPIRED_TOKEN"
console.error(result.failure.error.message); // human-readable description
console.error(result.failure.help); // actionable hint, when available
} else {
use(result.data.token); // result.data: TokenResult
}failure is a discriminated union — narrow on type to reach per-variant fields:
const created = AccessKeyStrategy.create(workspaceCrn, accessKey);
if (created.failure) {
if (created.failure.type === "WORKSPACE_MISMATCH") {
console.error(`expected ${created.failure.expected}, got ${created.failure.actual}`);
}
return;
}
const strategy = created.data;Failure types: INVALID_ACCESS_KEY, ACCESS_DENIED, EXPIRED_TOKEN, INVALID_GRANT, INVALID_CLIENT, INVALID_REGION, INVALID_URL, INVALID_TOKEN, SERVER_ERROR, REQUEST_ERROR, NOT_AUTHENTICATED, MISSING_WORKSPACE_CRN, INVALID_CRN, WORKSPACE_MISMATCH, INVALID_WORKSPACE_ID, ALREADY_CONSUMED, INTERNAL_ERROR, STORE_ERROR. Each failure also carries the live error: Error and optional help/url. Only a genuine internal panic still throws.
Migrating from the throw-based API (0.40.x and earlier): replace
try { const t = await s.getToken(); … } catch (err) { err.code }withconst r = await s.getToken(); if (r.failure) { r.failure.type } else { r.data }. Factories (AccessKeyStrategy.create,AutoStrategy.detect,OidcFederationStrategy.create,DeviceSessionStrategy.fromProfile) now return aResulttoo, so unwrap.databefore use.
Working with tokens directly (use with care)
The recommended integration is to hand your strategy to a CipherStash SDK — e.g.
Encryption from @cipherstash/stack, as shown above. The SDK calls
getToken() internally and manages refresh, so your code never handles a raw
credential.
If you have a lower-level need, getToken() returns the bearer token directly.
Treat it as a secret: never log it, return it to a browser, or persist it
outside a secure store.
const result = await strategy.getToken();
if (result.failure) {
console.error(result.failure.type);
return;
}
const { token, workspaceId, services } = result.data;
// `token` is the bearer credential — send it as `Authorization: Bearer ${token}`
// to a CTS service (e.g. ZeroKMS at `services.zerokms`).result.data is { token, subject, workspaceId, issuer, services }, where
services is a plain object (e.g. { zerokms: "https://..." }). See
Error handling for the failure arm.
Next.js App Router adapter — @cipherstash/auth/next
The @cipherstash/auth/next entry adapts OidcFederationStrategy to
request/response server frameworks. It's built for the Next.js App Router but is
framework-agnostic by construction — every function takes a WHATWG Request /
Headers and returns plain data, so it works anywhere you can read a request and
write response headers.
The model. Federate a third-party OIDC JWT into a CTS service token where the request is both in scope and able to write cookies (middleware, route handlers, server actions), then:
- persist the token to a per-workspace,
HttpOnlycookie (cs_token_<workspace-id>) — the cross-request cache, so later requests reuse it instead of re-federating; - warm the current request's render by handing the freshly minted token forward on a request header — a
Set-Cookiewritten now isn't readable in the same request, so the render can't see the cookie you just set.
Middleware — federate, warm, refresh
csFederationMiddleware throws on federation failure — including the
ordinary signed-out case, where there's no JWT to federate. Catch it, or every
unauthenticated request 500s in middleware:
// middleware.ts
import { NextResponse } from "next/server";
import { csFederationMiddleware, csSanitizeHeaders } from "@cipherstash/auth/next";
export async function middleware(request: Request) {
const responseHeaders = new Headers(); // the refreshed cookie is appended here
let requestHeaders: Headers;
try {
({ requestHeaders } = await csFederationMiddleware({
request,
responseHeaders,
workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn:<region>:<workspace-id>"
getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …)
}));
} catch {
// Signed out, or federation failed — let the request through unwarmed:
// `csAuthHeader` returns null downstream and the render falls back. Still
// strip the header, or a client-supplied one would reach the render forgeable.
requestHeaders = csSanitizeHeaders(request);
}
// Deliver the warmed token (if any) to this request's render...
const response = NextResponse.next({ request: { headers: requestHeaders } });
// ...and copy the refreshed `Set-Cookie` onto the response.
responseHeaders.forEach((value, key) => response.headers.append(key, value));
return response;
}requestHeaders is a clone of the incoming headers with any inbound
x-cs-cts-token deleted and the freshly minted one set — the strip is done by
the library, not left to your wiring. See
Security for why that
matters.
Reading the warmed token — Server Components, Route Handlers, protect-ffi
csAuthHeader(headers) reads the token the middleware warmed into an
AuthStrategy. The header is read eagerly and closed over, so the returned
strategy is safe to drive from a detached callback (e.g. protect-ffi). It returns
null when there's no warmed token, so you can fall back to a cold federation or
render a signed-out state.
This strategy does not refresh. Unlike
OidcFederationStrategyandAccessKeyStrategy, it hands back the same token on everygetToken()— it is valid only until that token's TTL expires, after which downstream ZeroKMS calls fail with no refresh path. Treat it as request-scoped: a detached callback within the request is fine, but don't cache the strategy across requests — re-read the header on the next one.
import { headers } from "next/headers";
import { csAuthHeader } from "@cipherstash/auth/next";
import { Encryption } from "@cipherstash/stack";
export async function loadSecret() {
const strategy = csAuthHeader(await headers());
if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run");
// Hand the strategy to a CipherStash SDK — it calls getToken() as needed.
// (The warmed strategy itself never refreshes; it's good for this request.)
const encryption = new Encryption({ authStrategy: strategy });
// ... encrypt / decrypt with `encryption` ...
}Without the warmed-header handoff — csFederate
If you only need a token inside a single writable, in-scope context — a route
handler that both authenticates and does the work — skip the middleware handoff
and call csFederate directly. It returns a TokenResult and throws on failure:
// app/api/data/route.ts
import { csFederate } from "@cipherstash/auth/next";
export async function GET(request: Request) {
const responseHeaders = new Headers();
const token = await csFederate({
request,
responseHeaders,
workspaceCrn: process.env.CS_WORKSPACE_CRN!,
getJwt: () => getSessionJwt(),
});
return Response.json({ workspaceId: token.workspaceId }, { headers: responseHeaders });
}Security — the warmed-token header is not authenticated
csAuthHeader reads an opaque base64url(JSON) payload from a request header; it
validates the shape of the TokenResult, but the payload is not
cryptographically authenticated. A forged header is therefore accepted as long as
it's well-formed — and since the validated shape includes an arbitrary services
map, a forgery can point your app at an attacker-controlled ZeroKMS endpoint.
The exposure is data and key exfiltration, not merely acting as the wrong
identity.
Only trust it where the inbound, client-supplied header is stripped before the
request reaches your code. csFederationMiddleware does this for you — its
requestHeaders is built by csSanitizeHeaders(request), which deletes any
inbound x-cs-cts-token — but that only covers requests the middleware actually
runs on:
- Every path from which
csAuthHeaderis reachable must be sanitised. Next.js middlewarematchers routinely exclude paths (static assets, some API routes); an excluded-but-reachable path is a forgery hole. - On the signed-out / federation-error path,
csFederationMiddlewarethrows, so callcsSanitizeHeaders(request)yourself — as the middleware example above does in itscatch.
Cryptographically pinning the payload to the app (AEAD seal/open with an app-held key), so an un-stripped header still can't be forged, is tracked in CIP-3112. Until that lands, the ingress strip is the only thing standing between an un-matched route and a forged token — treat CIP-3112 as a prerequisite for a production rollout rather than a nice-to-have.
API
| Export | Description |
|---|---|
| csFederationMiddleware(options) | Federate-or-reuse in middleware. Returns { result, requestHeaders, headerName, headerValue } — forward requestHeaders — and appends the refreshed cookie to responseHeaders. Throws on failure (incl. signed out). |
| csFederate(options) | Federate-or-reuse in any writable, in-scope context. Returns a TokenResult; throws on failure. |
| csSanitizeHeaders(source, options?) | Clone a Request/Headers with the warmed-token header deleted. The ingress strip that makes csAuthHeader trustworthy — use on every path it's reachable from. |
| csAuthHeader(headers, options?) | Read the warmed token into a no-federation AuthStrategy, or null if absent. The strategy does not refresh — request-scoped only. |
| csTokenCookieName(workspaceId) | The per-workspace cookie name, cs_token_<workspaceId>. |
| CS_TOKEN_HEADER | The default warmed-token request header, x-cs-cts-token. |
| encodeTokenHeader / decodeTokenHeader | The opaque base64url(JSON) header codec (used internally; exported for advanced wiring). |
options (shared by csFederate and csFederationMiddleware):
| Option | Default | Notes |
|---|---|---|
| request | — required — | Incoming Request (reads the token cookie) |
| responseHeaders | — required — | Outgoing Headers (the refreshed cookie is appended as Set-Cookie) |
| workspaceCrn | — required — | crn:<region>:<workspace-id> |
| getJwt | — required — | Returns the current third-party OIDC JWT (re-invoked on every re-federation) |
| baseUrl | region discovery | Pin federation to a specific CTS host / mock |
| cookieName | cs_token_<workspace-id> | Override the per-workspace cookie name |
| secure | true | Cookie Secure flag — set false only for localhost HTTP dev |
| sameSite | "Lax" | Cookie SameSite |
| headerName | x-cs-cts-token | (csFederationMiddleware only) request header to carry the warmed token |
License
Distributed under the PolyForm Internal Use License 1.0.0. A full copy is bundled with this package as LICENSE.
