You moved token checks out of twelve services and into the gateway, and the services read the
caller from x-user-id and x-user-scopes. A user sends x-user-scopes: orders:admin with their
own valid token, the gateway passes it along beside the identity it verified, and the
administrator endpoint answers with every customer’s orders.
What you get
You will end up with a gateway that strips identity headers, verifies the token, and signs what it forwards, and a service that refuses anything the gateway did not sign. This is for you if several services each check credentials, or already trust a header.
Short answer
Verify the token once at the gateway, pinning the algorithm, issuer, and audience. Before that,
strip every identity header from the inbound request, so a caller cannot send its own x-user-id.
Then forward the identity with a short-lived assertion the gateway signs, and have each service
verify it, because anything already inside the network can reach a service without passing the
gateway.
You will need
Node 22.18 or later, a service reachable over HTTP, and an OIDC provider or a signing key you
control. The sample plays the identity provider itself with an ES256 key pair, and verifies and
signs tokens with jose.
The gateway is plain node:http, so the same four steps map onto any gateway’s configuration.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Apache APISIX openid-connect | An OIDC provider already issues your tokens, and APISIX fronts the services | In the 3.16.0 source, nothing removes a client’s own X-Userinfo when the plugin sets none | You issue API keys rather than OIDC tokens |
| Cloudflare Access | Services behind Cloudflare, including Workers, with people signing in through SSO | Every origin must still validate the signed assertion, and Cloudflare sits in each request’s path | Machine clients holding your own tokens, or traffic that must stay on your network |
Kong key-auth and jwt plugins | API keys or JSON Web Tokens per consumer, with the consumer’s identity forwarded upstream | A gateway cluster and consumer records to operate, and upstream headers a direct caller can still forge | A handful of services that a shared library already covers |
| Per-service token middleware | Few services in one language, or a network where every hop authenticates | Verification settings in every service, and a release of each one whenever they change | Many services in several languages, where the copies drift apart |
The gateway gives you one choke point and one place to rotate keys. It costs you the bypass: a service that trusts headers trusts anything inside the network that can reach it. Middleware in each service has no bypass, because every hop verifies, and costs a deployment of every service whenever an issuer, an audience, or the library changes. The sample keeps the gateway and closes the bypass with a signed assertion, the design Cloudflare Access uses.
Strip, then verify, then inject
The order inside the gateway is the whole fix. Stripping comes first and runs for every request, including anonymous ones to public paths.
export function createGateway({ upstream, idpKey, issuer, audience, assertionKey, publicPaths = [], strip = true }: Options) {
return createServer(async (req, res) => {
// 1. Strip identity headers from the public request, whatever happens next. Hop-by-hop
// headers stop here too, including any a client lists in Connection to have removed.
const hop = new Set(['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
...String(req.headers.connection ?? '').split(',').map((h) => h.trim().toLowerCase())])
const headers: IncomingHttpHeaders = {}
for (const [name, value] of Object.entries(req.headers)) {
if (hop.has(name)) continue
if (strip && IDENTITY.test(name.toLowerCase().replaceAll('_', '-'))) continue
headers[name] = value
}
delete headers.authorization // the service gets the verified identity, not the raw token
// Framing is the gateway's to set. Left to the client, a GET with a chunked body, or one
// that lists content-length in Connection, goes upstream with no length, and the service
// reads the body as a second request that skipped every step here.
if (req.headers['content-length']) headers['content-length'] = req.headers['content-length']
else if (req.headers['transfer-encoding']) headers['transfer-encoding'] = 'chunked'
// 2. Verify the caller's token: signature, issuer, audience, expiry, and the algorithm.
// The target is parsed once, and only the normalised path goes upstream, so a service
// never routes a raw path like /admin/orders/../../status that was checked as /status.
let target: URL
try {
target = new URL(req.url ?? '/', 'http://gateway.internal')
} catch {
return res.writeHead(400).end() // http://[ and the like: refused, not thrown in an async handler
}
const path = target.pathname
const forward = target.pathname + target.search
const token = /^Bearer (\S+)$/.exec(req.headers.authorization ?? '')?.[1]
let claims: JWTPayload | null = null
if (token) {
try {
claims = (await jwtVerify(token, idpKey, { issuer, audience, algorithms: ['ES256'] })).payload
} catch {
return res.writeHead(401, { 'www-authenticate': 'Bearer error="invalid_token"' }).end()
}
} else if (!publicPaths.includes(path)) {
return res.writeHead(401, { 'www-authenticate': 'Bearer' }).end()
}
// 3. Inject the identity, and a short-lived assertion that only the gateway can sign,
// bound to this method and path, so a service can tell the gateway sent it.
if (claims?.sub) {
const scope = typeof claims.scope === 'string' ? claims.scope : ''
headers['x-user-id'] = claims.sub
if (scope) headers['x-user-scopes'] = scope
headers['x-gateway-assertion'] = await new SignJWT({ scope, method: req.method, path: forward })
.setProtectedHeader({ alg: 'ES256' })
.setSubject(claims.sub)
.setIssuer('gateway')
.setAudience('orders')
.setIssuedAt()
.setExpirationTime('30s')
.sign(assertionKey)
}
// 4. Forward, streaming both ways.
const up = request({ ...upstream, method: req.method, path: forward, headers }, (upRes) => {
res.writeHead(upRes.statusCode ?? 502, upRes.headers)
upRes.pipe(res)
})
up.on('error', () => res.headersSent ? res.destroy() : res.writeHead(502).end())
req.pipe(up)
})
}
The strip matches on a prefix, after converting to lowercase and turning underscores into hyphens, so
X-User-Scopes and x_user_role go too. A CGI-style stack maps x_user_id and x-user-id to the
same variable, as RFC 3875 specifies, and a strip by
exact name misses the first. The token is verified with the algorithm
pinned to ES256, which refuses a token signed some other way.1 The raw token stops at the
gateway, so a service log never holds a credential.
The assertion is an ES256 JWT that lives thirty seconds and names the method and path. Only the gateway holds the private key, and services get the public one.
Make the service check who sent the request
A service on a private network is still reachable from everything else on that network: a debug pod, another service, a compromised build agent. The service believes the assertion, not the headers beside it.
async function fromAssertion(req: IncomingMessage, gatewayKey: CryptoKey): Promise<Caller | null> {
const assertion = req.headers['x-gateway-assertion']
if (typeof assertion !== 'string') return null
try {
const { payload } = await jwtVerify(assertion, gatewayKey, {
issuer: 'gateway', audience: 'orders', algorithms: ['ES256'], maxTokenAge: '30s',
})
// Bound to one method and path, so a copied assertion opens nothing else.
if (payload.method !== req.method || payload.path !== req.url || !payload.sub) return null
return { id: payload.sub, scopes: String(payload.scope ?? '').split(' ').filter(Boolean) }
} catch {
return null
}
}
Cloudflare documents the same arrangement. Access adds a signed Cf-Access-Jwt-Assertion header,
and the origin should validate it “to ensure that the request came from Access and not a malicious
third party.”
Replay a request with the header set by hand
The check that matters is the forgery itself. pitfall.mjs replays three requests against three
setups: a gateway that forwards inbound headers, one that strips them, and one that strips and
signs.
node pitfall.mjs
replay forwards strips signs
alice, nothing forged, GET /orders 200 ord_1 200 ord_1 200 ord_1
alice, x-user-scopes forged, GET /admin/orders 200 ord_1 ord_2 ord_3 403 403
no token, x-user-id: bob, to the service 200 ord_2 ord_3 200 ord_2 ord_3 401
Alice’s own token plus a hand-set x-user-scopes returned all three orders through the forwarding
gateway, because the gateway sets that header only for users who have scopes. Stripping closed it.
The third row goes around the gateway altogether, and only the signed assertion stops it. Run the
same replay with curl -H against staging before you trust any gateway configuration.
Check it worked
The suite pins the strip, the token checks, the request framing, and the assertion’s limits. This test sends the forgeries in three spellings and checks what a service behind the gateway received.
test('forged identity headers are stripped in any case or spelling', async (t) => {
const e = await echoBehindGateway()
t.after(() => e.close())
const res = await fetch(`${e.url}/orders`, {
headers: {
...bearer(e.token),
'X-User-Scopes': 'orders:admin',
'x_user_role': 'admin',
'x-gateway-assertion': 'forged',
},
})
const seen = await res.json()
assert.equal(seen['x-user-id'], 'alice')
assert.equal(seen['x-user-scopes'], undefined, 'alice has no scopes, so none is forwarded')
assert.equal(seen['x_user_role'], undefined)
assert.notEqual(seen['x-gateway-assertion'], 'forged')
assert.equal(seen.authorization, undefined, 'the raw token stays at the gateway')
})
node --test gateway.test.mjs
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 560.682643
The others refuse a missing token, a wrong audience, an expired token, a stranger’s key, and an HS256 token. They also refuse an assertion copied onto another path or older than thirty seconds. Four more go through a raw socket: a duplicated header, a name with a trailing space, and a second request hidden in a body. The last two send a target that cannot be parsed and a path with dot segments, which must reach the service as the path the gateway checked.
When it goes wrong
The service acts on a request the gateway never verified, with a forged x-user-id. The request
travelled as the body of a GET to a public path, sent chunked or with content-length named in
Connection. The gateway dropped both framing headers, so Node’s http.request wrote the body
upstream with no length, and the service parsed it as a request of its own. The gateway sets the
length, or chunks the body again, before it forwards anything, and a test sends both forms.
A client asks the next proxy to delete the assertion. HTTP lets any sender list header names in
Connection, and an intermediary must remove the fields it names.2 The gateway drops the
client’s Connection header and everything it lists, and a test fails if that line goes.
The service rejects every request after a key rotation. It holds the gateway’s old public key.
Publish the gateway’s keys as a JWKS and fetch them with createRemoteJWKSet, so the service picks
up a key by its kid.
A header added at Kong or APISIX arrives with the client’s value. Kong’s
key-auth handler
clears each consumer header it does not set, so a client’s own X-Consumer-Username never
arrives. A header you add yourself gets no such clearing. The
request-transformer plugin runs its
removals before its additions, and its
add step
skips a header the request already carries, so list your header under both. APISIX’s
openid-connect sets X-Userinfo only when it has user info, and
3.17.0 added a
step that clears client copies first, which
3.16.0 lacks.
When not to do this
Do not move auth to the gateway and leave the services trusting plain headers. The third replay row is the cost, and it is paid by whoever can reach the service network.
Do not use a gateway to authorize individual objects. The gateway knows who the caller is. Whether
Alice may read order ord_2 is the service’s question, and answering it at the edge means teaching
the gateway every service’s data model.
Do not sign the assertion with a shared secret if services do not trust each other. With HMAC, every service that can verify an assertion can also mint one. A key pair keeps minting at the gateway.
Do not bother with a gateway for two services in one language. A shared middleware is less to operate, and it cannot be bypassed, because every service runs the check itself.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and 22.23.3 and jose 6.2.12. Every output block is what
the command before it printed, on both Node versions. The Kong and APISIX behavior was read from their source, not run.
Footnotes
-
RFC 8725, the JWT best current practice, lists attacks on libraries that believed a token’s own
algheader. One changes it to “none” and expects no signature check. Another changes RS256 to HS256 and expects the library to use the RSA public key as an HMAC secret. The remedy it prescribes is the one-line option in the gateway: the caller names the algorithms, and the library refuses the rest. ↩︎ Back to text -
RFC 9110 requires an intermediary to remove every header named in
Connectionbefore forwarding, and then theConnectionheader itself. The rule exists so that per-connection options do not leak past the hop they were meant for, and it hands any client a way to delete a header one hop further on. ↩︎ Back to text