@mandate-security/node
v0.3.0
Published
MANDATE verification for Node.js: hosted cross-referenced validation by default, plus the offline DEADMAN emergency tier and legacy local verification.
Maintainers
Readme
@mandate-security/node
Verification of MANDATE decisions for Node.js. Add one script tag on the frontend and keep production browser authority server-side with the HttpOnly Mndt-Sess continuity cookie or locked hosted validation. Hosted cross-referenced validation is the default authority for protected routes; local X-Mndt verification is the offline/emergency and compatibility path — ordinary low-risk routes, explicit server integrations, incident response when MANDATE is unreachable, and migrations. Local verification keeps no replay state and cannot consult replay markers, lineage, or session trust; high-value or replay-sensitive routes must call hosted validation with a tokens:validate API key.
npm scope:
@mandate-security— the@mandatescope is registered by an unrelated project on npm.
Requires Node.js 18+.
Quick start
1. Frontend (browser)
Add the MANDATE script before calling routes your backend protects. Public builds keep tokens private to the verifier runtime and attach X-Mndt automatically on same-origin protected requests.
<script src="https://gate.x-lock.dev/g.js?k=YOUR_SITE_KEY" async></script>const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cartId: "..." }),
});2. Backend (Node)
npm install @mandate-security/nodeSet env vars (shown once in the MANDATE dashboard):
MANDATE_SITE_SECRET=mss_live_... # server-only current secret
MANDATE_SITE_KEY=sk_live_... # optional but recommended
MANDATE_MIN_SCORE=0.75 # optional
MANDATE_PREVIOUS_SITE_SECRET=mss_live_old_... # rotation overlap only (optional)Current / previous keys: set MANDATE_SITE_SECRET (or siteSecret) to the current secret only. During an explicit dashboard rotation overlap, also set MANDATE_PREVIOUS_SITE_SECRET (or previousSiteSecret) so tokens/cookies sealed with the previous secret still verify. After you retire the previous version in the dashboard, remove the previous env var and redeploy — unknown/retired keys fail closed as invalid_token / signature. Optional currentKeyId / previousKeyId pin expected token kid values during overlap.
Verify a token:
import { verifyRequest } from "@mandate-security/node";
const result = verifyRequest(req.headers, {
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
minScore: 0.75,
});
if (!result.success) {
return res.status(403).json({ error: result.error });
}Or bind env vars once:
import { createVerifier } from "@mandate-security/node";
const mandate = createVerifier(); // reads MANDATE_* env vars
export function requireMandate(req) {
const result = mandate.verifyRequest(req.headers);
if (!result.success || result.action !== "allow") throw new Error(result.error ?? "forbidden");
return result;
}Production browser authority (Mndt-Sess)
In production, browsers carry an HttpOnly Mndt-Sess continuity cookie (max 5 minutes, path-bound). Verify it on first-party protected routes:
import { verifySessionCookieRequest } from "@mandate-security/node";
const result = verifySessionCookieRequest(req, {
siteSecret: process.env.MANDATE_SITE_SECRET,
// path is inferred from req.path / req.url when omitted
// previousSiteSecret also reads MANDATE_PREVIOUS_SITE_SECRET during rotation overlap
// allowLegacy: true, // only during an explicit v1 reader overlap; default false
});
// Prefer success + action checks — `human` is a legacy alias, not identity proof.
if (!result.success || result.action !== "allow") {
return res.status(403).json({ error: result.error });
}Local X-Mndt verification remains appropriate for ordinary low-risk routes, explicit server integrations, and migrations. Do not log raw cookie values, ciphertext, site secrets, or decrypted claims. Prefer summarizeDecision(result) for logs/metrics.
Ordinary local verification contract (no hosted dependency)
Ordinary routes verify entirely in-process with your site secret:
| Property | Behavior |
| --- | --- |
| Network | None — never calls MANDATE infrastructure |
| Timeout | N/A — synchronous CPU crypto; no I/O wait or deadline config |
| Failure | Structured { success: false, error } (fail-closed); adapters do not throw on bad credentials |
| High-value | Local highValue: true fails closed with high_value_requires_hosted_validation |
Measured p95 on Node 22 (darwin/arm64, 5000 iters): see docs/internal/evidence/m1-03-local-verify-benchmark.json. Soft target ≈ 5 ms; re-run with:
npm run benchmark:local-verify --prefix sdks/nodeObserve / enforce adapter semantics
| mode | sessionCookie | Missing/invalid credential |
| --- | --- | --- |
| observe | off (header X-Mndt) | Continues; req.mandate / context.mandate set |
| observe | observe or enforce | Continues (cookie path) |
| enforce | off | 403 (header path) |
| enforce | observe | Continues — measure cookie outcomes without blocking |
| enforce | enforce | 403 only when both are enforce |
Rollback: set mode: "observe" (and sessionCookie: "observe" if using cookies), redeploy, keep existing app auth. Do not flip broad enforce without an approved single-route plan.
Use hosted validation when a route needs stateful replay protection or local verification cannot run in your backend. Hosted validation is the default authority for protected routes; local verification remains for ordinary low-risk routes, offline incident response, and compatibility migrations, and setting highValue: true on local verification fails closed with high_value_requires_hosted_validation. Hosted validation consumes each raw X-Mndt token once and asks the gate to check live session, origin, route-seed, server-counter, delta-cadence, token-context, and browser request-proof continuity. High-value hosted validation requires a customer API key with the tokens:validate scope before the SDK makes the call:
import { validateHostedRequest } from "@mandate-security/node";
const result = await validateHostedRequest(req, {
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
apiKey: process.env.MANDATE_API_KEY,
gateUrl: process.env.MANDATE_GATE_URL, // optional; defaults to https://gate.x-lock.dev
highValue: true,
path: req.path,
origin: "https://app.example.com",
accountKey: req.user?.id,
});
if (!result.success) {
return res.status(403).json({ error: result.error });
}
// If result.token is set, it is a refreshed opaque token with updated
// server-side lineage/marker/route state for follow-on checks.
// Under abuse pressure, result.action can be "throttle", "fresh_proof", "queue", or "step_up".
// If result.queueToken is set, pass it back as queueToken on the server-side retry.Offline emergency verification (DEADMAN)
DEADMAN is the tourniquet tier: a short-lived, route-scoped proof your backend verifies entirely in-process with your site secret — zero API callback to MANDATE, so enforcement keeps working even if our edge is unreachable. Use it for night-one incident mitigation on attacked routes (login, order, redemption), then graduate to hosted cross-referenced verification, which remains the default authority for protected routes.
DEADMAN is stateless by design: it cannot consult replay markers, token lineage, or session trust, and there is no revocation. Replay is possible inside the token TTL (minutes, not hours). That residual risk is the tradeoff for a verifier that never phones home — a tourniquet, not the cure.
import { deadmanMiddleware } from "@mandate-security/node";
app.post(
"/api/login",
deadmanMiddleware({
siteSecret: process.env.MANDATE_SITE_SECRET,
routeClass: "login", // proofs minted for other route classes are denied
}),
loginHandler, // req.mandateDeadman carries the verified payload
);The middleware requires a valid proof on the md_kx header (case-insensitive; the x-mndt-deadman alias is accepted during transition). Missing or invalid proof is fail-closed: a uniform 403 {"error":"deadman_required"} that never reveals which check failed.
For direct verification, verifyDeadmanToken(token, { siteSecret, siteKey, routeClass }) returns { ok: true, payload } or { ok: false, reason } with stable reasons (malformed, bad_signature, decrypt_failed, wrong_type, expired, site_mismatch, route_mismatch). It never throws on bad input and never calls MANDATE infrastructure.
Express
import express from "express";
import { summarizeDecision } from "@mandate-security/node";
import { mandate } from "@mandate-security/node/express";
const app = express();
const mandateMode = process.env.MANDATE_MODE ?? "observe";
// Ordinary / migration: header X-Mndt
app.use(mandate({
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
mode: mandateMode, // observe first; enforcement needs approved route scope and rollback
minScore: 0.75,
onDecision: (d, req) => {
// Log only grouped fields — never raw tokens, cookies, or secrets.
console.log({ path: req.path, ...summarizeDecision(d) });
},
}));
// Production browser authority: HttpOnly Mndt-Sess (path-bound)
app.post(
"/api/checkout",
mandate({
siteSecret: process.env.MANDATE_SITE_SECRET,
mode: mandateMode,
sessionCookie: mandateMode === "enforce" ? "enforce" : "observe",
}),
(req, res) => {
if (mandateMode === "enforce" && (!req.mandate.success || req.mandate.action !== "allow")) {
return res.status(403).json({ error: req.mandate.error });
}
return res.json({ ok: true, mandate: { action: req.mandate.action } });
},
);Observe first: start in mode: "observe" (and sessionCookie: "observe" when using cookies) so every request proceeds and req.mandate is always set. Keep existing controls, review legitimate-user outcomes, and test rollback. Enforcement is an opt-in decision for one approved route, not a broad browser-blocking claim. Prefer success && action === "allow" over the legacy human alias.
For high-value routes that need hosted replay/session continuity and abuse escalation handling, use mandateHosted on the specific route:
import { mandateHosted } from "@mandate-security/node/express";
app.post(
"/api/login",
mandateHosted({
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
apiKey: process.env.MANDATE_API_KEY,
highValue: true,
origin: "https://app.example.com",
resolveHostedOptions: (req) => ({
accountKey: req.user?.id,
queueToken: req.get("X-Mandate-Queue-Token"),
clientIP: req.get("CF-Connecting-IP"),
asn: req.get("CF-ASN"),
country: req.get("CF-IPCountry"),
}),
}),
loginHandler,
);In enforce mode, hosted middleware maps throttle to 429, queue to 202 with queueToken, fresh_proof to 401, step_up to 403 with stepUp/stepUpAttemptId, and block to 403.
Next.js (App Router)
// app/api/checkout/route.js
import { withMandate } from "@mandate-security/node/next";
export const POST = withMandate(
async (_request, { mandate }) => {
if (process.env.MANDATE_MODE === "enforce" && (!mandate.success || mandate.action !== "allow")) {
return Response.json({ error: mandate.error }, { status: 403 });
}
return Response.json({ ok: true, mandate: { action: mandate.action } });
},
{
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
mode: process.env.MANDATE_MODE ?? "observe",
// Production browser authority (HttpOnly Mndt-Sess). Omit for ordinary X-Mndt routes.
// sessionCookie: process.env.MANDATE_MODE === "enforce" ? "enforce" : "observe",
},
);In mode: "enforce", failed verification returns 403 before your handler runs. With sessionCookie: "enforce", the middleware verifies Mndt-Sess instead of X-Mndt.
For hosted validation on a sensitive App Router route:
// app/api/login/route.js
import { withMandateHosted } from "@mandate-security/node/next";
export const POST = withMandateHosted(
async (_request, { mandate }) => {
return Response.json({ ok: true, sessionId: mandate.sessionId });
},
{
siteSecret: process.env.MANDATE_SITE_SECRET,
siteKey: process.env.MANDATE_SITE_KEY,
apiKey: process.env.MANDATE_API_KEY,
highValue: true,
origin: "https://app.example.com",
resolveHostedOptions: (request) => ({
accountKey: request.headers.get("X-Account-Key") ?? undefined,
queueToken: request.headers.get("X-Mandate-Queue-Token") ?? undefined,
clientIP: request.headers.get("CF-Connecting-IP") ?? undefined,
asn: request.headers.get("CF-ASN") ?? undefined,
country: request.headers.get("CF-IPCountry") ?? undefined,
}),
},
);API reference
verify(token, options) / verifyMandateToken(token, options)
Verify a raw token string with compatible local header-token verification; it adds no MANDATE validation round trip. For sensitive local routes, pass path, endpoint, or route; high-route tokens then must carry a matching route proof or verification returns route_proof_missing / route_proof_mismatch.
verifyRequest(headers, options)
Read X-Mndt from Fetch Headers, Express req, or a plain header map, then verify. Pass endpoint metadata in options when the route should enforce a local high-route proof.
verifySessionCookie(value, options) / verifySessionCookieRequest(source, options)
Verify production browser authority from the HttpOnly Mndt-Sess cookie (v2 encrypted envelope with gate parity). Options: siteSecret, optional previousSiteSecret / MANDATE_PREVIOUS_SITE_SECRET (rotation overlap only), path (binding; empty → /, max 256), now, allowLegacy (default false; not env-enabled), and optional expected token-payload bindings (sessionId, siteKey, routeSeed, origin, tokenId, generation).
Stable errors: missing, malformed, version, signature, lifetime, expired, issued_in_future, path_mismatch, site_mismatch, session_mismatch, route_seed_mismatch, origin_mismatch, token_mismatch, generation_mismatch, unavailable. Never log raw cookies, ciphertext, or secrets. Use summarizeDecision for customer-safe logs.
summarizeDecision(result)
Returns grouped fields only (success, action, score, sessionId, siteKey, keyId, routeClass, error) for metrics/logs. Never includes raw tokens, cookies, or secrets.
createVerifier(options?)
Returns { verify, verifyRequest, verifySessionCookie, verifySessionCookieRequest, validateHosted, validateHostedRequest, readToken, readSessionCookie, header }. Reads MANDATE_SITE_SECRET, MANDATE_SITE_KEY, MANDATE_MIN_SCORE, optional MANDATE_PREVIOUS_SITE_SECRET, optional MANDATE_GATE_URL, and optional MANDATE_API_KEY when options are omitted. Bound verify methods are fail-closed (never throw on bad credentials).
validateHosted(token, options) / validateHostedRequest(headers, options)
Calls locked hosted POST /validate for high-value or replay-sensitive paths that need one-shot token consumption plus server-side replay, session-continuity, origin, route-seed, server-counter, delta-cadence, token-context, and browser request-proof checks. Pass the full request object when available so validateHostedRequest can forward X-Mndt-Req, method, and path. Options include gateUrl, validateUrl, apiKey, siteSecret, siteKey, minScore, highValue, endpointValue, path, endpoint, route, requestProof, requestMethod, origin, siteOrigin, accountKey, queueToken, and hosted middleware resolveHostedOptions.
When available from trusted edge/backend metadata, also pass clientIP, ipAddress, asn, and country so MANDATE can score token replay across network changes without making local verification phone home.
Hosted validation does not expose raw scores. Reusing the same hosted-validated token returns token_replayed; high-value stale lineage can return stale_token, delta_cadence_stale, route_proof_missing / route_proof_mismatch, or request_proof_missing / request_proof_mismatch. It may return token, a refreshed opaque token with updated server-side lineage/marker state. Result shapes expose tokenMarker as a compact diagnostic marker: p means provisional and e means escalated. Customers should not branch security policy on marker values directly.
Hosted validation may report routeClass as low, mid, or high; when the gate returns a route-scoped refresh, tokenTtlSeconds describes the refreshed token lifetime (900, 300, or 60).
For throttled abuse-escalation decisions, hosted validation may include cache/stale-content hints: cacheAction, cacheTtlSeconds, and staleIfErrorSeconds.
Result shape
{
success: boolean;
action: "allow" | "observe" | "throttle" | "fresh_proof" | "queue" | "step_up" | "challenge" | "block" | null;
score: number | null; // local verification only; hosted validation returns null
human: boolean; // legacy alias for success && action === "allow"; not identity proof
siteKey: string | null;
sessionId: string | null;
expiresAt: number | null; // unix seconds
token?: string | null; // hosted validation only
routeClass: string | null; // low | mid | high when route-scoped
tokenTtlSeconds: number | null;
tokenMarker: "p" | "e" | string | null; // compact marker, diagnostic only
reason: string | null;
reasons: string[];
retryAfterSeconds: number | null;
queueToken: string | null;
recommendedStepUp: string | null;
stepUpAttemptId: string | null;
cacheAction: string | null;
cacheTtlSeconds: number | null;
staleIfErrorSeconds: number | null;
error?: "missing_token" | "invalid_token" | "expired_token"
| "token_replayed" | "stale_token" | "delta_cadence_stale"
| "route_proof_missing" | "route_proof_mismatch"
| "request_proof_missing" | "request_proof_invalid"
| "request_proof_stale" | "request_proof_mismatch"
| "request_proof_unavailable"
| "wrong_site_key" | "below_min_score" | "blocked" | "challenge";
}Notes
- Production browser authority uses the HttpOnly
Mndt-Sesscookie (max 5 minutes, path-bound, encrypted v2). Ordinary local routes useX-Mndtheader tokens (default 15-minute TTL). Hosted/validateis the default authority for protected routes and is required for high-value/replay-sensitive one-shot consumption. - Tokens and session cookies are opaque encrypted binary (not JWTs). Semantic claims are not customer-loggable credentials.
- Local verification keeps no replay state. It can enforce endpoint-bound high-route token proofs when you pass
path,endpoint, orroute; use hosted validation for high-value paths that need MANDATE's live replay/session/token-context/request-proof checks. - Never expose your site secret to the client. Never log raw
Mndt-Sess/X-Mndtvalues, decrypted claims, or secrets.
Docs
Full documentation: x-lock.dev/docs
Generate an integration guide for your app:
./mandate init --target ./your-app --framework expressTests
Shared crypto vectors: test/token-vectors.json.
Shared protocol corpus (M1-02): test/browser-credential-protocol-fixtures.json (X-Mndt + Node cookie cases).
cd sdks/node && npm test
npm run benchmark:local-verify --prefix sdks/node # M1-03 p95 evidenceLicense
MIT
