Skip to content

Authentication

vgi-rpc provides built-in authentication factories for common strategies. Each factory returns an AuthenticateFn that you pass to createHttpHandler. You can also combine multiple strategies with chainAuthenticate.

Every authenticate callback returns an AuthContext on success:

import { AuthContext } from "@query-farm/vgi-rpc";
const ctx = new AuthContext(
"mtls", // domain — identifies the auth method
true, // authenticated
"alice", // principal — the identity
{ role: "admin" }, // claims — arbitrary metadata
);

Handlers access the context via ctx.auth:

handler: (params, ctx) => {
ctx.auth.requireAuthenticated(); // throws if not authenticated
return { user: ctx.auth.principal };
},

For API keys, opaque tokens, or tokens validated against an external service:

import { bearerAuthenticate, AuthContext } from "@query-farm/vgi-rpc";
const auth = bearerAuthenticate({
validate: (token) => {
const user = db.getUserByApiKey(token);
if (!user) throw new Error("Invalid API key");
return new AuthContext("apikey", true, user.name, { role: user.role });
},
});

For development, testing, or a small number of pre-shared keys. Uses constant-time comparison:

import { bearerAuthenticateStatic, AuthContext } from "@query-farm/vgi-rpc";
const auth = bearerAuthenticateStatic({
tokens: {
"key-abc123": new AuthContext("apikey", true, "alice"),
"key-def456": new AuthContext("apikey", true, "bob", { role: "admin" }),
},
});

Accepts Record<string, AuthContext> or ReadonlyMap<string, AuthContext>.

Validates JWT tokens using OIDC discovery. Requires the oauth4webapi peer dependency.

import { jwtAuthenticate } from "@query-farm/vgi-rpc";
const auth = jwtAuthenticate({
issuer: "https://auth.example.com",
audience: "https://api.example.com/vgi",
});
Option Type Default Description
issuer string required Expected iss claim (used for OIDC discovery)
audience string | string[] required Expected aud claim
jwksUri string Explicit JWKS URL (discovered from issuer if omitted)
principalClaim string "sub" JWT claim to use as principal
domain string "jwt" AuthContext.domain value

When audience is an array, each value is tried in order until one validates — useful when a single endpoint accepts tokens minted for several audiences.

Proxy proof lets a worker refuse any request that did not arrive through a trusted proxy. The proxy mints a per-request HMAC-SHA256 over a timestamp, a fresh nonce and the worker’s own identifier, keyed by a secret shared only with that worker.

Unlike a forwarded assertion about what happened at a TLS terminator, a proof cannot be produced by someone who merely reaches the worker directly — without the secret there is nothing to replay.

Built on WebCrypto rather than node:crypto, so it works on workerd and in browsers as well as Node and Bun.

import { parseProofSecrets, requireProxyProof } from "@query-farm/vgi-rpc/http";
const authenticate = requireProxyProof(
{
mode: "require",
originId: "worker-a",
secrets: parseProofSecrets(`prod-use1:${hexSecret}`),
},
existingAuthenticate, // optional — omit for proof-only
);
const handler = createHttpHandler(protocol, { authenticate, proxyProofRequired: true });
option type default description
mode "off" | "allow" | "require" allow verifies and records without denying; the rollout lever
originId string This worker’s identifier; folded into every MAC but never transmitted
secrets Record<string, ProofSecret> Key id → secret and label
skewSeconds number 30 Half-width of the timestamp acceptance window
replayCapacity number 100000 Hard bound on the nonce cache
replayCache boolean true Whether replayed nonces are rejected

kid doubles as the calling proxy’s label, so ctx.claims.vgi_proxy_proof.proxy records which proxy served each request. The label comes from the secret that verified, never from the transmitted field — a caller can claim any kid but cannot produce its MAC.

OPTIONS, /.well-known/ and {prefix}/health stay reachable without a proof in every mode, so load-balancer probes keep working.

proxyProofRequired: true makes the handler emit VGI-Proxy-Proof-Required: true on every response, including GET {prefix}/health, and adds it to Access-Control-Expose-Headers. It is advertisement only — it enforces nothing, and the gate above is what denies.

Set it only with mode: "require". allow never denies, so advertising there would tell a proxy the hop is protected when it is not. The declaration is manual because the gate reaches the handler as an opaque AuthenticateFn, which the handler cannot introspect for its mode; without the header a proxy has no way to distinguish an enforcing worker from one that silently ignores the header — exactly the misconfiguration that turns the feature into a no-op.

The normative cross-language contract is docs/proxy-proof-spec.md in the vgi-rpc repository.

For proxy-terminated mTLS connections where the reverse proxy forwards client certificate information in HTTP headers. Two approaches are supported:

  • PEM-in-header — the proxy forwards the URL-encoded PEM certificate (e.g., nginx X-SSL-Client-Cert, AWS ALB X-Amzn-Mtls-Clientcert)
  • XFCC — the proxy forwards an Envoy x-forwarded-client-cert structured header

Extracts the Subject Common Name as principal and populates claims with the DN, serial number, and expiry:

import { mtlsAuthenticateSubject } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateSubject({
allowedSubjects: new Set(["my-service", "other-service"]),
checkExpiry: true,
});
Option Type Default Description
header string "X-SSL-Client-Cert" Header containing URL-encoded PEM
domain string "mtls" AuthContext.domain value
allowedSubjects ReadonlySet<string> | null null Restrict accepted CNs (null = accept any)
checkExpiry boolean false Reject expired or not-yet-valid certificates

The returned AuthContext has these claims:

Claim Description
subject_dn Full DN string (e.g., "CN=my-service")
serial Serial number as hex string
not_valid_after Expiry as ISO 8601 string

Maps certificate fingerprints to pre-configured identities. Useful when you have a known set of client certificates:

import { mtlsAuthenticateFingerprint, AuthContext } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateFingerprint({
fingerprints: {
"a1b2c3d4...": new AuthContext("mtls", true, "service-a"),
"e5f6a7b8...": new AuthContext("mtls", true, "service-b", { env: "prod" }),
},
algorithm: "sha256", // default; also supports sha1, sha384, sha512
});

Fingerprints must be lowercase hex without colons. Throws at construction time if the algorithm is unsupported.

Option Type Default Description
fingerprints Record<string, AuthContext> | ReadonlyMap<string, AuthContext> required Fingerprint-to-context map
header string "X-SSL-Client-Cert" Header containing URL-encoded PEM
algorithm string "sha256" Hash algorithm
checkExpiry boolean false Reject expired certificates

For full control over certificate inspection:

import { mtlsAuthenticate, AuthContext } from "@query-farm/vgi-rpc";
import type { X509Certificate } from "node:crypto";
const auth = mtlsAuthenticate({
validate: (cert: X509Certificate) => {
// Inspect cert.subject, cert.issuer, cert.serialNumber, etc.
return new AuthContext("mtls", true, "my-principal", {});
},
header: "X-Amzn-Mtls-Clientcert", // AWS ALB header
checkExpiry: true,
});
Option Type Default Description
validate CertValidateFn required Receives X509Certificate, returns AuthContext or throws
header string "X-SSL-Client-Cert" Header containing URL-encoded PEM
checkExpiry boolean false Check validity period before calling validate

Parses the x-forwarded-client-cert structured header set by Envoy proxy. Does not require certificate parsing — works with the structured fields directly:

import { mtlsAuthenticateXfcc } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateXfcc();
// Extracts CN from Subject field as principal

With custom validation:

import { mtlsAuthenticateXfcc, AuthContext } from "@query-farm/vgi-rpc";
import type { XfccElement } from "@query-farm/vgi-rpc";
const auth = mtlsAuthenticateXfcc({
validate: (element: XfccElement) => {
if (element.hash !== "expected-hash") throw new Error("Unknown client");
return new AuthContext("xfcc", true, "trusted-service", {});
},
selectElement: "first", // "first" (original client) or "last" (nearest proxy)
});
Option Type Default Description
validate XfccValidateFn Custom validation (default: extract CN from Subject)
domain string "mtls" AuthContext.domain value
selectElement "first" | "last" "first" Which element to use when multiple are present

Accept multiple authentication methods on the same endpoint with chainAuthenticate. Authenticators are tried in order — credential errors (plain Error) fall through to the next; other exceptions propagate immediately.

import {
chainAuthenticate,
mtlsAuthenticateSubject,
bearerAuthenticateStatic,
AuthContext,
} from "@query-farm/vgi-rpc";
const auth = chainAuthenticate(
mtlsAuthenticateSubject({ allowedSubjects: new Set(["my-service"]) }),
bearerAuthenticateStatic({
tokens: { "sk-ci-bot": new AuthContext("apikey", true, "ci-bot") },
}),
);
const handler = createHttpHandler(protocol, {
tokenKey: myKey,
authenticate: auth,
});

When a request arrives with a client certificate header, mTLS is tried first. If the header is missing, the chain falls through to bearer token authentication.

Scenario Exception type Result
Credentials accepted (none) Returns AuthContext, stops chain
Bad / missing credentials Plain Error Tries next authenticator
Authenticated but forbidden Error with name === "PermissionError" Propagates immediately
Bug in authenticator TypeError, RangeError, etc. Propagates immediately

cookieAuthenticate wraps any inner authenticator so it can also accept the bearer token from a browser cookie (default name _vgi_auth) rather than only the Authorization header. It reads the cookie, rewrites the request with an Authorization: Bearer <token> header, and delegates to the inner authenticator:

const auth = cookieAuthenticate(jwtAuthenticate({ issuer, audience }));

This is what powers the browser session cookie set by the OAuth PKCE flow. cookieAuthenticate lives in the library’s http module (it is not re-exported from the package root).

For interactive, browser-based sign-in, vgi-rpc can serve RFC 9728 OAuth Protected Resource Metadata, run the PKCE authorization-code redirect flow, handle the /_oauth/* routes, and emit WWW-Authenticate challenges. See the OAuth guide for the full setup, including the device-code flow and how the cookie session described above is issued.

Pass any authenticate callback to createHttpHandler:

import { Protocol, createHttpHandler, float } from "@query-farm/vgi-rpc";
const protocol = new Protocol("MyService");
protocol.unary("add", {
params: { a: float, b: float },
result: { result: float },
handler: ({ a, b }) => ({ result: a + b }),
});
const handler = createHttpHandler(protocol, {
tokenKey: myKey,
authenticate: auth, // any AuthenticateFn from above
});
Bun.serve({ port: 8080, fetch: handler });

tokenKey is the XChaCha20-Poly1305 master key (32 bytes) used to seal stream-state tokens; a random key is generated if omitted. Supply your own when tokens must survive a restart or be shared across load-balanced workers.

Unauthenticated requests receive a 401 Unauthorized response.