@sealed-api-gateway/core
v1.0.2
Published
A same-origin BFF gateway for Next.js that seals every browser API call into one opaque POST. Zero dependencies, zero configuration.
Maintainers
Readme
What it does
Without it, your Network tab tells anyone who opens DevTools exactly how your API works:
GET https://api.example.com/v1/orders?page=2&status=pending 200
POST https://api.example.com/v1/orders 201
Authorization: Bearer eyJhbGciOi…With it, those same calls look like this:
POST /api/gw/_x 200
POST /api/gw/_x 200
POST /api/gw/_x 200No hostname. No path. No query string. No real status code. No response body. No Cookie or Authorization header.
It covers every HTTP client at once — fetch, axios, SWR, React Query — because it wraps the two browser primitives they all end up calling. Nothing in your own code changes.
[!IMPORTANT] This is obfuscation, not secrecy. The browser has to decrypt in order to render, so anyone willing to read the running app gets the plaintext back, and your backend hostnames are still in the JavaScript bundle. It removes casual visibility; it does not make data secret. Your backend must still authorize every request. → Threat model
At a glance
| | Supported |
| --- | --- |
| fetch | ✅ |
| axios | ✅ |
| React Query · SWR | ✅ |
| Any client built on fetch or XMLHttpRequest | ✅ |
| Next.js App Router | ✅ 13.4 – 16 |
| Server Action transport (no route file) | ✅ default |
| Route handler transport | ✅ fallback |
| Automatic re-handshake and retry | ✅ |
| Multi-instance / horizontal scaling | ✅ with SECURE_GATEWAY_KEY |
| Zero runtime dependencies | ✅ |
| Streaming responses | ❌ — why |
| Pages Router | ❌ |
| Edge runtime | ❌ — why |
Architecture
Before — every hop is visible in DevTools:
Browser ──────────────────────────────▶ Backend
GET /v1/orders?page=2
Authorization: Bearer …After — one opaque hop, and the second is server-to-server:
Browser ──────────────▶ Your Next server ──────────────▶ Backend
POST (sealed) GET /v1/orders?page=2
no URL, no path, Authorization: Bearer …
no status, no headersYour backend now only ever sees traffic from your own server, so it can be firewalled to that single origin.
Contents
- Install · Quick start
- Configuration → everything you can change
- How it works · Threat model
- Compatibility · Troubleshooting
- The debug console · API
- FAQ · Design decisions · Security principles
- Ecosystem · Roadmap
Install
npm i @sealed-api-gateway/corePeers you almost certainly already have: next (≥ 13.4) and react (≥ 18.2). Nothing else — see zero dependencies.
Quick start
Two steps. No route file, no middleware change.
1. Mount the component
// app/layout.tsx
import { SecureApiGateway } from '@sealed-api-gateway/core';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SecureApiGateway />
{children}
</body>
</html>
);
}2. Point it at your backends
Any one of these — they are additive, so use whichever matches how your project already works:
# .env — if your backend URLs already live here, this is zero extra work
NEXT_PUBLIC_API_URL=https://api.example.com
# .env — an explicit list, if they do not
SEALED_GATEWAY_ORIGINS=https://api.example.com,https://auth.example.com// sealed-gateway.config.json — if you hardcode URLs in code
{ "origins": ["https://api.example.com", "https://auth.example.com"] }Restart, open DevTools, and your API calls are gone.
[!TIP] No axios instance to rewire, no endpoint list, no per-call opt-in. Requests to origins you have not declared — S3 presigned uploads, analytics, fonts — are left completely untouched.
Do I need a route file?
Usually not. The gateway reaches its server half through Server Actions,
for which Next creates an endpoint automatically — nothing to add to your app/
directory. On the wire that is a POST to the current page URL, so there is not
even a /api/gw/_x to notice.
You need one in exactly three cases:
| Situation | Why |
| --- | --- |
| You installed @sealed-api-gateway/console | It still reaches the gateway over HTTP for its handshake and unlock. |
| Next 13.4 / 13.5 without experimental.serverActions | Server Actions are stable from Next 14; below that they need a config flag, and the route is the zero-config path. |
| You prefer a real endpoint | Pass transport="route" and it is used unconditionally. |
If any apply, the CLI writes it for you:
npx sealed-gateway-init// app/api/gw/[...path]/route.ts ← generated for you
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export { GET, POST } from '@sealed-api-gateway/core/server';Next derives routes from the filesystem and offers no API for a library to register one, so any route a package needs must physically exist in your project. The CLI writes it so you never have to.
dynamic and runtime must be literal. Next reads route-segment config by
static analysis before any code runs, so a re-exported value is invisible to it.
The route would be statically optimised, and every visitor served one cached
handshake — containing the same ephemeral key. runtime = 'nodejs' is likewise
required: the crypto uses Node APIs the Edge runtime does not provide.
npx sealed-gateway-init --dry-run # print it, write nothing
npx sealed-gateway-init --route /api/proxy # mount somewhere else
npx sealed-gateway-init --dir src/app # if auto-detection missesChoosing the transport
| transport | Behaviour |
| --- | --- |
| 'auto' (default) | Prefer Server Actions; fall back to the route if unavailable. Decided by trying, not by checking versions — experimental.serverActions is not observable from the client, and a wrong guess would silently disable the gateway. |
| 'action' | Force Server Actions. Fails loudly if unusable. |
| 'route' | Force the mounted route. |
<SecureApiGateway transport="route" />Configuration
The gateway needs no configuration to work. Everything below is optional.
The one thing you must declare: your backends
Three sources, additive — use whichever suits your project:
| Source | Declare it like | Best for |
| --- | --- | --- |
| NEXT_PUBLIC_* variables | NEXT_PUBLIC_API_URL=https://api.example.com | Projects already keeping backend URLs in the environment — zero extra work |
| SEALED_GATEWAY_ORIGINS | SEALED_GATEWAY_ORIGINS=https://a.example.com,https://b.example.com | URLs that live nowhere in the environment |
| sealed-gateway.config.json | { "origins": ["https://api.example.com"] } | Projects that hardcode URLs in code and would rather declare them there |
JSON rather than JS for the config file, deliberately: the server reads it at runtime from inside a security boundary, and a format that had to be executed would mean running project code there. A malformed file throws naming the offending entry rather than being ignored.
[!WARNING] This list is the security boundary. The browser names its own target inside the sealed envelope, so without an allowlist the gateway would be an open relay into whatever your server can reach. Because the list comes from the environment — which a caller cannot influence — only your declared backends are reachable; anything else is rejected with
403.Only
http:andhttps:are honoured.file:,ftp:,data:andjavascript:all parse as valid URLs and are refused.
Everything you can change
Every value below has a working default. Set none of them and the gateway behaves exactly as documented.
Environment variables — server-only
| Variable | Default | What it changes |
| ----------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
| SECURE_GATEWAY_KEY | generated at boot | Pins the encryption keypair.Required for multi-instance deploys → why |
| SECURE_GATEWAY_PASSWORD | unset | Switches on thedebug console. Unset ⇒ nothing is recorded anywhere. |
| SECURE_GATEWAY_TIMEOUT_MS | 30000 | How long to wait for your backend. Range 1 000 – 300 000. |
| SECURE_GATEWAY_MAX_BODY_MB | 25 | Largest sealed request accepted. Range 1 – 512. A memory guard, not a policy. |
| SECURE_GATEWAY_RATE_LIMIT | 600 | Requests per minute per IP against the sealed endpoint. |
| SECURE_GATEWAY_UNLOCK_TTL_HOURS | 8 | How long a console unlock lasts. Range 1 – 720. |
| SECURE_GATEWAY_FORWARD_HEADERS | — | Extra request headers to forward, comma-separated. x-tenant-id,x-trace |
| SECURE_GATEWAY_BLOCK_RESPONSE_HEADERS | — | Extra response headers to withhold, comma-separated. |
A malformed value throws at boot, naming the variable and the value, rather than silently falling back. SECURE_GATEWAY_TIMEOUT_MS=30s quietly becoming 30 seconds would be indistinguishable from it working.
Component props
| Prop | Type | Default | What it changes |
| ------------ | ---------- | ------------- | --------------------------------------------------------------------------------- |
| basePath | string | '/api/gw' | Where you mounted the route. Must match the folder holding[...path]/route.ts. |
<SecureApiGateway basePath="/api/proxy" />What you deliberately cannot change
| Fixed behaviour | Why |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| set-cookie is always stripped from responses | An upstream setting cookies onyour origin through the proxy is session fixation. |
| content-length and content-encoding are always stripped | The body is re-serialised, so the original values would be lies the browser acts on. |
| Forwarded headers are anallowlist, never a blocklist | A header the browser can set and the gateway blindly relays is a path past your app into your own backend. You can add to it; you cannot open it. |
| The wire carries onlyGET and POST | Every other verb travelsinside the envelope. More verbs on the wire would be more for an observer to distinguish. |
Multi-instance deployments
Without SECURE_GATEWAY_KEY, each server process generates its own keypair. A browser that handshook with instance A gets a 409 from instance B, re-handshakes and retries — and across several instances a real share of requests exhaust their retries:
| Instances | Calls that fail | | --------- | --------------- | | 1 | 0 % | | 2 | ~13 % | | 3 | ~30 % | | 4 | ~42 % |
Single process: skip it. PM2 cluster, Kubernetes replicas, or serverless: set it.
npx sealed-gateway-key >> .env # then restartThe generator strips any previous SECURE_GATEWAY_KEY line rather than accumulating them.
[!CAUTION]
SECURE_GATEWAY_KEYis server-only. Never prefix it withNEXT_PUBLIC_— that would inline the private key into the browser bundle and let anyone decrypt the traffic.
How it works
browser ──POST /api/gw/_x──▶ your Next server ──GET https://api.example.com/v1/orders?page=2──▶ backend
◀──200 (sealed)──── ◀──200 { … }───────────────────────────────────ECIES-style, and stateless on the server:
- The server holds a P-256 keypair — from
SECURE_GATEWAY_KEY, or generated at boot. GET /api/gw/_pkreturns its public key plus the origin allowlist.- The browser generates an ephemeral P-256 pair and ECDH-derives a shared AES-GCM-256 key.
- Every request carries the browser's ephemeral public key, so the server re-derives the same key per request and needs no session store — it survives cold starts and horizontal scaling.
- Envelope on the wire:
base64( ephemeralPublicKey ‖ iv ‖ ciphertext ). - The response is sealed with the same key and a fresh IV. The wire status is always
200— the real status is inside.
A stale key yields 409; the client re-handshakes and retries up to three times.
Threat model
What it defends
| Threat | How | | -------------------------------------- | ------------------------------------------------------------------------------------------------ | | Casual enumeration of your API surface | Paths, query strings and statuses never appear on the wire | | Token harvesting from the Network tab | The token travels inside the ciphertext, not in a header or cookie | | Direct traffic to your backend | It only ever receives requests from your Next server, so it can be firewalled to that one origin | | Scrapers built by reading DevTools | The observable surface is one opaque endpoint |
What it does not
| Not defended | Why |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| A determined operator of the browser | Decryption is client-side by necessity — hookfetch before the gateway does and the plaintext is yours |
| Discovery of backend hostnames | They ship in the client bundle, because the browser must know what to route |
| An unauthenticated or over-permissive backend | The gateway forwards requests; it does not authorize them |
| That a request happened, its size, or its timing | Only the contents are hidden |
Compatibility
| | Supported |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Next.js | 13.4 – 16, App Router |
| React | 18.2 – 19 |
| Node | ≥ 18.17 — the crypto usesglobalThis.crypto.subtle |
| Runtime | nodejs. Edge is not supported. |
| Context | HTTPS orlocalhost. In a non-secure context crypto.subtle is unavailable and the module declines to patch anything rather than half-working. |
On Next 13/14 with React 19, npm needs --legacy-peer-deps — those versions declare react: ^18.2.0. It builds and runs; the metadata simply predates React 19.
Pages Router is not supported. The route handler is App Router only.
Zero dependencies
"dependencies": {}, enforced by a test and by prepublishOnly. The crypto is globalThis.crypto.subtle directly — no node-forge, no jose, no polyfill. Your lockfile grows by one line.
Limitations
Design consequences, not defects:
- Responses cannot stream. A body must be complete before it can be encrypted.
- Upload and download progress events do not fire, and
xhr.timeoutis not honoured on sealed calls. Requestobjects passed tofetchbypass sealing — their body is a stream that cannot be re-read. Pass a URL andinitinstead.- The rate limiter is per-process. Serverless instances do not share the counter; put a real limiter at the edge if you need a hard guarantee.
Troubleshooting
| Symptom | Cause | Fix |
| ----------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------ |
| Real URLs still visible in the Network tab | Not a secure context, so the module declined to patch | Serve over HTTPS orlocalhost |
| Everything 404s | Route file missing or in the wrong folder | npx sealed-gateway-init |
| 403 on a request that should work | Target origin is not in the allowlist | Add it as aNEXT_PUBLIC_*_URL and restart |
| Intermittent failures under load | Multiple instances, each with its own keypair | SetSECURE_GATEWAY_KEY |
| Afetch call is not sealed | ARequest object was passed | Pass a URL andinit instead |
| Server throws at boot naming aSECURE_GATEWAY_* variable | A tunable has a malformed value | Fix it — the message names the variable and the value |
| npm refuses to install on Next 13/14 + React 19 | Peer-dependency metadata | npm install --legacy-peer-deps |
The debug console
The gateway makes traffic unreadable in DevTools. @sealed-api-gateway/console is where you read it back — a Network-tab-style inspector with a folding JSON tree, binary preview, Copy-as-cURL and charts.
It needs no route file of its own; its unlock endpoint hangs off the route you already created.
<SecureApiGateway />
<GatewayConsole />SECURE_GATEWAY_PASSWORD=pick-something-longThen Ctrl/Cmd + Shift + D on any page.
API
// client
import { SecureApiGateway, DEFAULT_GATEWAY_ROOT, getGatewayRoot, setGatewayRoot } from '@sealed-api-gateway/core';
// server — route handlers, the allowlist, the crypto primitives, the tunables
import { GET, POST, deriveOrigins, seal, open, timeoutMs, rateLimit } from '@sealed-api-gateway/core/server';
// the plaintext log, shaped for useSyncExternalStore
import { subscribe, getEntries, getServerEntries } from '@sealed-api-gateway/core/recorder';FAQ
Does this replace authentication? No. The gateway forwards requests; it does not authorize them. An endpoint that is unsafe when called directly is equally unsafe through the gateway.
Does it stop attackers? No. It removes casual visibility from the Network tab. Treat it as defence in depth, never as the defence.
Can backend URLs still be found? Yes — they are in the JavaScript bundle, because the browser has to know what to route. The gateway hides them from the network tab, not from the bundle.
Are requests actually encrypted? Yes. ECDH P-256 key agreement, AES-GCM-256 per request with a fresh IV.
Does it work with axios / fetch / React Query / SWR?
All of them, unmodified. It wraps window.fetch and XMLHttpRequest, which
every browser HTTP client ends up calling.
Does it affect SSR or Server Components? No. It patches browser globals only; server-side fetches are untouched.
Does it support streaming responses? No — see design decisions.
What happens if I set no password? The debug console does not exist, its endpoint 404s, and nothing is recorded anywhere. That is the default.
Can it run on Kubernetes / PM2 / serverless?
Yes. Set SECURE_GATEWAY_KEY so every instance shares one keypair — without it
each process generates its own and clients re-handshake on instance switches.
Does it work behind a reverse proxy or CDN?
Yes. Forward x-forwarded-for if you want the per-IP rate limit to see real
client addresses rather than your proxy.
Design decisions
The reasoning behind the choices most likely to raise an eyebrow.
Why only GET and POST on the wire?
Every other verb travels inside the encrypted envelope and is reissued
server-side. Fewer observable request shapes make traffic less descriptive, and
more exported verbs would mean more surface for an observer to distinguish.
Why no streaming? A body must be complete before it can be sealed — AES-GCM authenticates the whole message. Streaming and authenticated encryption of the full payload are mutually exclusive here, and silently shipping unauthenticated chunks would be worse than not streaming.
Why AES-GCM? It is authenticated. A tampered ciphertext fails to decrypt rather than yielding plausible garbage, so a mangled body can never reach your application as data.
Why P-256 rather than X25519?
crypto.subtle supports P-256 in every browser and Node version in range.
X25519 is cleaner but is not universally available, and a polyfill would break
the zero-dependency guarantee.
Why nodejs runtime, not Edge?
The password check uses node:crypto for its timing-safe comparison, which Edge
does not provide. A non-constant-time fallback would leak the password by timing.
Why zero dependencies?
This is security-adjacent code. Every dependency is supply-chain risk and one
more thing an auditor has to read. The crypto is globalThis.crypto.subtle
directly.
Why is the allowlist server-side and non-negotiable?
The browser names its own target inside the envelope. If the client could add to
the allowlist, any caller could point the gateway at 169.254.169.254 and read
your cloud credentials. Fail-closed is the only safe default.
Security principles
- Zero dependencies — nothing to audit but this package.
- Stateless — the shared key is re-derived per request; no session store, survives cold starts and horizontal scaling.
- No cookies on sealed calls — credentials travel inside the ciphertext.
- Per-request encryption — a fresh IV every time.
- Explicit allowlist — declared server-side, never by the client.
- Fail closed — an unconfigured deployment grants nothing; a non-secure context disables the module rather than half-working.
- Honest scope — the README states what this does not protect, in the first screen.
Ecosystem
Works alongside, with no special configuration:
| | |
| --- | --- |
| Auth | NextAuth · Auth.js · Clerk · your own |
| Data | React Query · SWR · axios · fetch |
| Hosting | Vercel · PM2 · Docker · Kubernetes · any Node host |
Auth libraries are unaffected because the gateway operates below them: it patches the transport, not the caller.
Roadmap
| Status | |
| --- | --- |
| ✅ | Gateway with Server Action and route transports |
| ✅ | Password-gated debug console |
| ✅ | Three ways to declare origins |
| ☐ | Console on Server Actions, so it needs no route either |
| ☐ | Wildcard origins — https://*.example.com |
| ☐ | Deployment guides and worked examples |
Changelog · Issues · Contributing
Licence
MIT © 2026 JAINEEL PATEL
