How-to › Run message-based services

How to add auth at the gateway for a service system#

Verify tokens once at the gateway, strip the identity headers callers send, and sign what it forwards, so a hand-set x-user-id cannot promote anyone.

Audience
Platform team
Level
intermediate
Topic
Expose services over HTTP and at the edge
Languages
JavaScript and TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Apache APISIX openid-connectAn OIDC provider already issues your tokens, and APISIX fronts the servicesIn the 3.16.0 source, nothing removes a client’s own X-Userinfo when the plugin sets noneYou issue API keys rather than OIDC tokens
Cloudflare AccessServices behind Cloudflare, including Workers, with people signing in through SSOEvery origin must still validate the signed assertion, and Cloudflare sits in each request’s pathMachine clients holding your own tokens, or traffic that must stay on your network
Kong key-auth and jwt pluginsAPI keys or JSON Web Tokens per consumer, with the consumer’s identity forwarded upstreamA gateway cluster and consumer records to operate, and upstream headers a direct caller can still forgeA handful of services that a shared library already covers
Per-service token middlewareFew services in one language, or a network where every hop authenticatesVerification settings in every service, and a release of each one whenever they changeMany 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.

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

  1. RFC 8725, the JWT best current practice, lists attacks on libraries that believed a token’s own alg header. 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

  2. RFC 9110 requires an intermediary to remove every header named in Connection before forwarding, and then the Connection header 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.