@jorvel/security
Edge-runtime-safe primitives (Web Crypto, no Buffer, no node:crypto) for federation security. Every helper is tree-shakable and side-effect-free.
Runtime guarantees
crypto.subtle, fetch,TextEncoder, and standard JS.CSP
Build a strict-dynamic + nonce CSP that lets federated remoteEntry scripts execute while blocking everything else. cspMiddleware wraps Express/Connect; edge handlers call buildCsp directly.
buildCsp(opts?: {
nonce?: string; // base64url; required for strict-dynamic
strictDynamic?: boolean; // default true when nonce present
strictStyles?: boolean; // drops 'unsafe-inline' from style-src
reportTo?: string;
extra?: Record<string, string[]>; // merged directive overrides
}): string;
cspMeta(opts?: Parameters<typeof buildCsp>[0]): string; // <meta http-equiv="...">
generateNonce(bytes?: number): string; // default 16 bytes → 22-char base64url
cspMiddleware(opts: {
remotes?: string[];
reportOnly?: boolean;
extra?: Record<string, string[]>;
}): RequestHandler; // Express/Connect
cspFastifyHook(opts): FastifyPreHandler;
cspHeaderFactory(opts): (req: { url: string }) => { header: string; nonce: string };Wiring a strict-dynamic CSP into an SSR response
import { buildCsp, generateNonce, safeJsonForScript } from '@jorvel/security';
export async function handler(req: Request): Promise<Response> {
const nonce = generateNonce();
const csp = buildCsp({
nonce,
strictDynamic: true,
extra: { 'connect-src': ['https://api.acme.com'] },
});
const html = template
.replace('__NONCE__', nonce)
.replace('__STATE__', safeJsonForScript(state));
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-security-policy': csp,
},
});
}SRI
Subresource Integrity hashes for federation entry scripts. Generate the hash at build time with sriHashFromUrl or compute on-demand from in-memory content.
sriHash(content: string | ArrayBuffer | Uint8Array, algo?: 'sha256' | 'sha384' | 'sha512'): Promise<string>;
sriAttributes(content, algo?, crossorigin?: 'anonymous' | 'use-credentials'): Promise<{ integrity: string; crossorigin: string }>;
sriHashFromUrl(url: string, opts?: { algo?: 'sha256' | 'sha384' | 'sha512'; allowHttp?: boolean }): Promise<string>;Build-time manifest of remote SRI hashes
import { sriHashFromUrl } from '@jorvel/security';
const remotes = [
'https://cdn.acme.com/dashboard/remoteEntry.js',
'https://cdn.acme.com/billing/remoteEntry.js',
];
const manifest = Object.fromEntries(
await Promise.all(remotes.map(async (url) => [url, await sriHashFromUrl(url, { algo: 'sha384' })])),
);
// → { 'https://cdn.acme.com/dashboard/remoteEntry.js': 'sha384-...' }Allowlist
Single-label (*) and multi-label (**) wildcards. Defense-in-depth even with SRI — rejects URLs at the registry level before the network request fires.
new RemoteAllowlist(rules: string[], opts?: { schemes?: string[] });
// rules: 'https://cdn.acme.com' — exact
// 'https://*.acme.com' — single-label wildcard
// 'https://**.cdn.acme.com' — multi-label wildcard
allow.allows(url: string, name?: string): boolean;
allow.assertAllowed(url: string, name?: string): void; // throws on miss
allow.isAllowed(url: string, name?: string): boolean;import { RemoteAllowlist } from '@jorvel/security';
import { getRemoteRegistry } from '@jorvel/runtime';
const allow = new RemoteAllowlist([
'https://cdn.acme.com',
'https://*.preview.acme.com',
]);
getRemoteRegistry().setGuard((url) => allow.allows(url));Rate limiting
Token-bucket rate limiter for protected endpoints (auth, write paths). In-memory LRU by default; bring your own store for distributed enforcement.
createRateLimitGuard(opts: {
capacity: number; // burst
refillPerSec: number; // sustained
keyFor: (req: { url: string; headers: Record<string, string> }) => string;
store?: RateLimitStore; // default: in-memory LRU
maxKeys?: number; // default 10_000
}): (req) => {
allowed: boolean;
headers: Record<string, string>; // X-RateLimit-*
response?: { status: 429; headers; body };
};
interface RateLimitStore {
consume(key: string, amount: number): Promise<{ remaining: number; resetMs: number }>;
}Edge worker usage
const limiter = createRateLimitGuard({
capacity: 30,
refillPerSec: 5,
keyFor: (req) => req.headers['cf-connecting-ip'] ?? 'anon',
});
export default {
async fetch(req) {
const decision = await limiter({ url: req.url, headers: Object.fromEntries(req.headers) });
if (!decision.allowed) {
return new Response('Rate limit', { status: 429, headers: decision.headers });
}
return handle(req);
},
};Audit log
Structured audit logger with built-in PII redaction. Multiple sinks; the buffer sink is ideal for tests.
new AuditLogger(opts: {
sinks: AuditSink[];
redactKeys?: string[]; // case-insensitive metadata key match
defaultRedactions?: boolean; // password, token, cookie, secret — default true
});
audit.success(entry: AuditEntry): Promise<void>;
audit.denied(entry: Omit<AuditEntry, 'metadata'> & { reason: string }): Promise<void>;
audit.error(entry: AuditEntry & { error: unknown }): Promise<void>;
interface AuditSink { (entry: NormalizedEntry): Promise<void> | void }
bufferSink(): { sink: AuditSink; drain(): NormalizedEntry[] };OAuth helpers
Stateless helpers for PKCE flow + state-cookie protection. The package never stores anything itself — you persist via cookies / KV.
generatePkcePair(): Promise<{ verifier: string; challenge: string }>;
buildAuthorizeUrl(opts: { authorizeEndpoint, clientId, redirectUri, scope, state, codeChallenge }): string;
exchangeCodeForToken(opts: { tokenEndpoint, clientId, redirectUri, code, verifier }): Promise<TokenResponse>;
constantTimeEqual(a: string, b: string): boolean; // for state-cookie comparison
generateStateValue(bytes?: number): string;Sessions
Stateless signed-cookie sessions (HMAC-SHA256). See Authentication.
class SessionManager<T> {
constructor(opts: { secret: string; verifySecrets?: string[]; cookieName?; maxAge?; cookie?; now? });
sign(data: T): Promise<string>; // payload.signature token
verify(token): Promise<T | null>; // null if invalid/expired/tampered
seal(data: T): Promise<string>; // Set-Cookie (HttpOnly Secure SameSite=Lax)
destroy(): string; // Set-Cookie clearing the session
read(src: string | Request): Promise<T | null>;
requireUser(src: string | Request): Promise<T>; // throws SessionRequiredError (status 401)
}
getSession<T>(src, opts): Promise<T | null>;
requireUser<T>(src, opts): Promise<T>;
// OAuth provider presets + profile fetch
OAUTH_PROVIDERS.github | .google | .microsoft; // { authorizationEndpoint, tokenEndpoint, userinfoEndpoint, defaultScope }
fetchUserInfo<T>(provider, accessToken, fetcher?): Promise<T>;CSRF
Signed double-submit cookie. See Forms & CSRF.
issueCsrfToken(opts?: { cookieName?; headerName?; fieldName?; secret?; cookie? }):
Promise<{ token: string; setCookie: string }>;
verifyCsrf(req: { method; headers }, opts?, submittedToken?):
Promise<{ ok: true } | { ok: false; reason: 'missing-cookie'|'missing-token'|'mismatch'|'bad-signature' }>;
csrfFieldName(opts?): string;
// low-level cookie/crypto primitives
serializeCookie(name, value, opts?): string;
parseCookieHeader(header): Record<string, string>;
randomToken(bytes?): string;
hmacSign(data, secret): Promise<string>; hmacVerify(data, sig, secret): Promise<boolean>;
timingSafeEqual(a, b): boolean;Sandbox bridge
Postmessage bridge between a sandboxed iframe and the host. The bridge enforces an origin allowlist and a schema per message kind. Use for third-party widgets where ShadowRemoteisn't isolation enough.
createSandboxBridge<E>(opts: {
iframe: HTMLIFrameElement;
origin: string | string[]; // exact or wildcard
schemas: { [K in keyof E]?: Validator<E[K]> };
}): SandboxBridge<E>;
interface SandboxBridge<E> {
send<K extends keyof E>(kind: K, payload: E[K]): void;
on<K extends keyof E>(kind: K, handler: (payload: E[K]) => void): () => void;
destroy(): void;
}Sanitize
escapeHtml(s: string): string;
safeJsonForScript(value: unknown): string;
isSafePathname(path: string): boolean;
pruneProtoKeys<T extends object>(obj: T): T; // strips __proto__, constructor, prototypesafeJsonForScript
Use whenever you inline server-rendered state into a <script> tag. Escapes </script>, line separator (U+2028), and paragraph separator (U+2029) — the three sequences that turn a JSON blob into a script-injection vector.
<script id="__STATE__" type="application/json">
{safeJsonForScript(state)}
</script>Composite security middleware
cspMiddleware, cspFastifyHook, and cspHeaderFactory are pre-baked compositions of CSP + nonce + Referer-Policy + X-Content-Type-Options. They cover ~80% of production header policies; reach for the lower-level builders when you need custom directives.
