Skip to content

About

Auth proxy — token validation and caching reverse proxy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

auth.proxy

Last updated: 2026-10-22

CI codecov License

This repository is an optional perimeter gate that sits outside the three-layer separation of concerns (authentication & token issuance / authorization decision / authorization enforcement) of the auth stack.

Reverse proxy that sits between clients and downstream services. Operates in one of two mutually exclusive modes selected at deploy time via auth.mode.

Responsibility

Role. auth.proxy is an optional gate at the perimeter of the auth stack, not one of its three layers: auth.provider authenticates and issues tokens, auth.policy-verifier makes authorization decisions, and the downstream service — or protobuf.interceptors inside it — enforces them. Besides the upstream it forwards to, auth.provider is the only service the proxy calls.

One deployment runs one of two modes:

  • Validation — in front of a resource server. It checks an inbound Authorization: Bearer token against auth.provider's introspection endpoint, caches the answer, and forwards or refuses the request.
  • Injection — in front of a browser-facing (BFF) service. It exchanges the browser's session cookie at auth.provider's token endpoint for an access token and injects it as Authorization: Bearer, so the browser never holds the token; opt-in, it also exchanges an external JWT for a first-party token.

Owns:

  • the per-request decision — forward, refuse, or inject — and the wire shape of its refusals (including the RFC 6750 WWW-Authenticate challenge in validation mode);
  • its calls to the provider: POST /oauth/introspect, and POST /oauth/token for the session grant and the jwt-bearer exchange, with its own client authentication — client_secret_basic or private_key_jwt;
  • per-instance, in-memory caches of provider answers, and the single-flight that coalesces concurrent misses;
  • its configuration schema, logging and graceful shutdown.

Does not own:

Why a separate service. The stack's architecture runs each component as a standalone HTTP service that talks to the others only through endpoint URLs in configuration: the proxy needs only the provider's endpoint, and can be replaced by any token-validating reverse proxy (e.g. Envoy with ext_authz) with no application code change. Being in front of the service is what lets downstream services receive pre-validated requests without implementing auth logic themselves, and lets injection mode keep access tokens out of the browser (the OWASP Token Handler Pattern). Being optional, a deployment can leave it out.

Source map. Each source directory's README states its responsibility, role and invariants; what a single file does is in that file's header comment. Start at src/README.md (the source tree), config/README.md (the configuration schema), and the module READMEs — src/express, src/oauth, src/modes/injection, src/modes/validation.

Operating modes

auth.mode is required — must be set explicitly to "validation" or "injection". Omission or a typo causes the proxy to fail to start. Set it via HOCON (auth.mode = "validation") or the AUTH_MODE environment variable.

Validation mode (auth.mode = "validation")

Validates inbound Authorization: Bearer <token> headers against the provider's introspection endpoint. A request with no Authorization header, or an empty one, is forwarded unchanged (public endpoints remain reachable). A request whose Authorization is anything other than exactly Bearer, one space and an RFC 6750 §2.1 b64token (letters, digits, -._~+/, then any = padding) — another scheme, a lowercase bearer, a Bearer with no token, two spaces, or anything after the token — is refused 400 Invalid Token Type (see the challenge table below), and so is a request with more than one Authorization header line. Whenever a token is forwarded, the header the upstream receives is therefore Bearer and the one token that was introspected: no upstream that reads the header its own way (the last word, a regex to the end of the line) can find in it a token the provider never saw. Whitespace around the whole value is not part of it (RFC 9110 §5.5) and is dropped before either side reads it.

Flow:

  1. Detects Authorization: Bearer <token> header (passes through if there is no Authorization header, or an empty one; any other Authorization, or more than one, is 400).
  2. Checks in-memory cache keyed by SHA-256 of the token.
  3. On cache miss, calls provider's POST /oauth/introspect. A redirect from it is not followed — the endpoint is configuration — and is answered 502 Provider Configuration Error. Concurrent misses on the same token coalesce into a single provider call (single-flight), as they do in injection mode.
  4. Returns 401 if active: false; forwards the request if active: true. Only a 200 is read as an answer: another 2xx, even one carrying active: true, is a provider failure (see the logging table below).

Challenges, without and with VALIDATION_REALM (here api):

Refusal No realm Realm set
401: an access token the proxy would not accept (RFC 6750 §3) Bearer error="invalid_token" Bearer realm="api", error="invalid_token"
400: a Bearer credential that is not exactly Bearer <b64token> (Bearer, Bearer t, Bearer t extra), or more than one Authorization line when any of them names the Bearer scheme Bearer error="invalid_request" Bearer realm="api", error="invalid_request"
400: another method (Basic …, or a lowercase bearer, which this proxy does not admit), one line or several — no error code, as §3.1 says none Bearer realm="api"

A 413, 500, 501, 502 or 504 carries none, because none of them is a statement about the caller's Bearer credential. Injection mode answers no challenge on any path, deliberately: its caller holds a session cookie, not a Bearer token.

Two limits worth knowing. A browser cannot read the header cross-origin — WWW-Authenticate is not CORS-safelisted and this proxy sets no Access-Control-Expose-Headers — so a SPA on another origin sees the status and the body only. And invalid_token invites a client to fetch a new token and retry (§3.1), which cannot help in the audience case described below: a token the provider calls active: false because its aud names neither this proxy's client nor an audience registered in that client's allowedAudiences is refused the same way however fresh it is.

Logging. A provider failure is logged once for each request it answers — requests that share one provider call (single-flight) each log it — with the request id as requestId, the error under error and one of these events:

Event Level When
validation.token_unauthorized info The provider answered 401 about the caller's token (401 Invalid Token).
validation.provider_config_error error The provider refused the proxy's own client credentials, or the endpoint redirected (502 Provider Configuration Error).
validation.provider_error error Any other provider failure — 5xx, 429, another 4xx, a 2xx other than 200, a response that is not an introspection response, a timeout or a network error (502 Bad Gateway).
validation.unexpected_error error Anything else thrown (500 Internal Server Error).

A token the provider refuses is the caller's problem, not the proxy's, so it is logged at the level injection.session_unauthorized has and an alert on error-level lines does not fire for it. An active: false answer is not logged.

Benefits over JWT-only local validation:

  • Detects a revoked token — within the introspection cache TTL, and the only validation mode that can (see Revocation and the access-token lifetime).
  • Introspection results are cached (default 30s TTL), reducing provider load.
  • Downstream services receive pre-validated requests without implementing auth logic.

Validation supports unbound Bearer tokens. A response carrying any cnf or a non-Bearer token_type is refused with 401; introspection alone does not verify possession of a DPoP key or client certificate for the incoming request. Cache entries never outlive a supplied numeric exp, and a token that expires during introspection is refused immediately. An absent exp uses the configured TTL; a malformed exp is treated as a provider response error. TTL zero disables caching.

Introspection client identity

CLIENT_ID with CLIENT_SECRET or CLIENT_KEY is optional, and the choice between setting them and leaving them unset changes which tokens the provider will call active.

With client authentication (CLIENT_ID, and CLIENT_SECRET or CLIENT_KEY). The proxy authenticates to POST /oauth/introspect as its own client — with HTTP Basic, or with a client assertion when it holds a key (see Client authentication with a private key) — and the provider then pins the introspected token's audience to what the calling client may address — its own client_id and its allowedAudiences (auth.provider v0.12.0 and later; earlier releases pinned it to the client_id alone): a token whose aud names neither comes back active: false. There is no error and no diagnostic — on the wire an audience mismatch is indistinguishable from a forged or expired token, so the proxy answers 401 for tokens that are in fact perfectly valid.

The client the proxy authenticates as must therefore be associated with the audience of the tokens it validates. Either:

  • register that audience in the client's allowedAudiences, or
  • give the client the resource URI as its own client_id.

A proxy fronting https://api.example.com/orders that authenticates as a client_id which is neither that URI nor lists it in allowedAudiences answers 401 to every request whose token was minted with an RFC 8707 resource audience — which, wherever resource indicators are in use, is every request it sees.

What a provider 401 means. RFC 7662 §2.1 has the introspection request authenticated, and §2.3 answers an invalid credential with 401, so the provider's 401 answers whichever credential it carried. Without client credentials the inbound token is that credential and the 401 is about the caller: 401 Invalid Token. With them the credential is the proxy's own — its Basic header or its client assertion — the 401 refused the proxy, and the caller's token was never examined — that is 502 Provider Configuration Error, logged at error as validation.provider_config_error (introspect refused the proxy's client credentials) rather than at info as the caller's validation.token_unauthorized, because it is the operator's to fix. The injection path reports the same situation as provider_config_error 502 — the exchange always has, and the session grant since #95 F47. A misconfigured deployment therefore answers a status that clients and gateways retry more readily than 401, which spends the same per-instance introspection budget described under Provider rate limiting; the fix is the credential, not the retry policy.

Without client authentication (all unset). The proxy presents the inbound token itself as the introspection credential — buildAuthHeader emits Authorization: Bearer <token> with the same token in the body, which the provider requires to match. On that path the calling client is never identified, so the audience pin does not apply and a token for any audience is introspected on its own merits.

What that trades away:

  • The provider stops checking aud for you. It records the gap and returns the claims. The proxy does not check aud either, so a token minted for a different resource server validates here. Unless the upstream service checks aud itself, that is a confused-deputy gap — set client authentication and register the audience, or check aud upstream.
  • No client identity in the provider's audit trail for these calls.
  • Signature, issuer, token type, expiry and the revocation denylist are still checked, so a revoked or forged token is still active: false.

Rate limiting is unaffected by the choice — it is keyed on the proxy's IP either way (see Provider rate limiting).

Credentials containing reserved characters. RFC 6749 §2.3.1 requires both halves to be application/x-www-form-urlencoded-encoded before they are joined with : and base64'd into the Basic header; otherwise a : inside either half re-splits the credential in the wrong place and the provider reads a different pair than the one configured. clientSecretBasic (src/oauth/client-secret-basic.mts) does this, and the provider decodes with the matching form-urlencoded decoder, so a credential containing reserved characters round-trips byte for byte. Set the raw value in the environment variable — do not pre-encode it yourself.

Injection mode (auth.mode = "injection")

Translates an inbound session cookie into an outbound Authorization: Bearer token. Realizes the OWASP OAuth 2.0 BCP Token Handler Pattern — browsers never hold access tokens.

Flow:

  1. Extracts the session cookie named in auth.injection.sessionCookieName.
  2. If absent, forwards the request unchanged (the service layer decides whether authentication is required). A cookie that is present but refused (see Cookie forwarding) is forwarded the same way, but logged.
  3. On cache hit, injects the cached Bearer and forwards — only for a request that showed it is same-origin (see Cache behavior).
  4. On cache miss, exchanges the cookie for an access token via the provider's POST /oauth/token with grant_type=session. Concurrent misses on the same cookie, among requests the same-origin gate admits, coalesce into a single provider call (single-flight).
  5. Injects Authorization: Bearer <token> and forwards upstream.

Whatever the proxy forwards, it forwards without the session cookie: the pair is removed from Cookie and the other cookies go on (see Cookie forwarding).

Opt-in, injection mode can also exchange an external credential presented as Authorization: Bearer <JWT> for a first-party token — see External credential exchange.

Cache behavior

  • In-memory, per-instance. Restart and horizontal scale-out produce cold caches. Peak provider load during rollout ≈ instance count × active sessions.

  • TTL: min(auth.injection.tokenCache.ttlSeconds, provider.expires_in) - safetyMarginSeconds, counted from the instant the grant request was sent — not from the response, so a slow provider cannot push an entry past the token's expiry. Default 60s minus 5s safety margin. A grant whose response arrives after that point is not cached.

  • Every cache miss is a provider call that counts against the provider's per-source rate limit — the browser's source when the provider trusts this proxy, this instance's own otherwise. See Provider rate limiting before lowering the TTL.

  • Only a same-origin request is answered from the cache. The provider holds the session grant to a same-origin check, and a cache hit never reaches the provider. So the cache, and a grant another request is already making, answer only a request that passes the same check as far as this proxy can see:

    • it arrived over the scheme of auth.injection.publicOrigin (INJECTION_PUBLIC_ORIGIN), as this proxy sees it — from a TLS-terminating load balancer, that is https only when the load balancer is listed in INJECTION_TRUSTED_PROXIES. The provider refuses plain http when its cookies are secure, which this proxy cannot see, so it requires the public origin's scheme;
    • its Origin equals publicOrigin, or it has no Origin and says Sec-Fetch-Site: same-origin;
    • it sends no Sec-Fetch-Site other than same-origin;
    • its Referer, when it names an origin, names publicOrigin.

    While publicOrigin is unset, only a request over https with no Origin, Sec-Fetch-Site: same-origin, and no Referer naming an origin. Browsers send a same-origin Referer by default, so set INJECTION_PUBLIC_ORIGIN, or the session cache is effectively off: nearly every browser request then calls the provider, spends from its per-source budget (60 a minute by default), and a provider 429 reaches the caller as a 5xx (see Provider rate limiting). The proxy warns about it once at startup (injection.public_origin_unset). Any other request with the session cookie — a foreign or missing Origin, a foreign Referer, another scheme, a top-level navigation, a client that sends neither header — calls the provider itself, with its own headers, and gets the provider's answer: 401 session_required from a provider that refuses it. Its grant is not cached. Such requests are logged injection.grant_fetch with sameOrigin: false, and each one is a provider call that counts against the provider's rate limit.

Revocation and the access-token lifetime

For a downstream service that validates JWTs offline, the access token's own lifetime bounds replay exposure after logout at the provider. A downstream service that introspects tokens can observe tracked-session invalidation sooner.

With UserSession tracking configured, the provider's session grant requires a live tracked session whose subject matches the browser user, and stamps its sid on the access token. It issues no refresh token and therefore no refresh-token family_id. POST /session/logout invalidates the tracked UserSession and associated federation state before destroying the browser session. Successful tracked-session invalidation makes introspection return active: false and userinfo refuse the token; store failures are logged as described in the provider's operator runbook.

tokenCache.ttlSeconds bounds how long this proxy keeps re-injecting a token it already holds without asking the provider again. Lowering it makes a new request on a logged-out session reach the provider sooner. An unauthenticated browser session returns 401; a retained browser session with a missing, revoked or inconsistent tracked record returns 400 invalid_grant. The injection proxy maps either rejection to its existing 401 session_required response. A shorter cache TTL does not recall tokens already forwarded, and it multiplies the calls this proxy makes to /oauth/token (see Provider rate limiting).

So, for an operator:

  • Keep the provider's access-token lifetime short in a BFF topology. oauth.accessToken.defaultExpiresIn (expiresIn in older providers, which newer ones keep as a deprecated alias) bounds replay exposure at offline validators. The proxy owns the cookie-to-token exchange, so a short lifetime costs a grant call while the browser session remains valid.
  • A downstream service that validates the JWT offline cannot learn about a logout at all. Signature, iss, aud and exp are everything an offline validator checks, and none of them changes when a session ends. For such a service the revocation window is the token lifetime, with nothing available to shorten it.
  • Introspection-based validation is the only mode that can observe a revocation. A resource server — or an auth.mode = "validation" proxy in front of one — calling POST /oauth/introspect asks the provider on every cache miss, so a token the provider has stopped vouching for comes back active: false within that introspection cache TTL.

The validation proxy also caps each introspection cache entry at the token's exp. A zero introspection TTL bypasses the cache, so each request that is not concurrent with another for the same token asks the provider about the tracked session. Concurrent requests for one token still share a single call — the single-flight is independent of the TTL — so a burst sees the answer the provider gave to the first of them. This setting belongs to validation mode; it does not change the injection proxy's token cache or an offline validator's behavior.

Scope boundary

One proxy instance serves one OAuth scope domain. auth.injection.clientId and auth.injection.scope are fixed at deploy time, as are the exchange's clientId, scope, audience and resource. Serve multiple scope domains with multiple proxy instances.

A provider response of 400 invalid_grant is mapped to 401 session_required, so a revoked session prompts authentication rather than appearing as a proxy configuration failure. Other provider 400 responses retain their configuration-error mapping. Conversely, a provider 401 is session_required unless its error is invalid_client, which means the proxy's own client was refused — its auth.injection.clientId, or with a key its client assertion — a configuration failure that signing in again cannot fix, answered 502 provider_config_error rather than a login prompt.

Only a 200 is a successful token response on either injection path (RFC 6749 §5.1). Any other 2xx — even one carrying a token — is 502 provider_unavailable, "unexpected provider response", with the status in the message.

A redirect from the token endpoint is not followed on either injection path: it is 502 provider_config_error, with the status in the message. The endpoint is configuration, and a followed redirect cannot be reported honestly. A 307 or 308 to the same origin replays the whole POST — session cookie and form body — to a path nothing configured, and mints a token there. The same statuses cross-origin have the cookie stripped, so the provider answers 401 and the caller is told to authenticate again over an endpoint that is merely misconfigured. A 301, 302 or 303 turns the grant into a GET with no body, which a token endpoint answers 405.

CSRF responsibility boundary

The proxy is a transparent augmentation layer. It injects the Bearer but does NOT enforce CSRF. It only keeps its token cache from skipping the provider's own same-origin check on the session grant (see Cache behavior): a request that does not show it is same-origin is never served a cached token. Combine with SameSite=Lax cookies, same-origin deployment, and CSRF protection in the upstream service. That protection cannot be tied to the session cookie, which the upstream never receives (see Cookie forwarding): a synchronizer token kept in a session looked up by that cookie does not work behind this proxy. A CSRF cookie of its own (double-submit), an Origin / Sec-Fetch-Site check, or a claim in the injected token does. Being transparent cuts the other way too — see Inbound Authorization headers.

Inbound Authorization headers

The proxy overrides an inbound Authorization header only on the requests where a session cookie actually produced a token (logged as injection.authorization_override, an inbound Authorization: with an empty value included). On the two paths where it minted nothing — no session cookie at all, or a cookie the grammar check refused — the request is forwarded as-is, inbound Authorization header included.

The upstream service must verify the token it is handed. It must not read "a Bearer header arrived on the connection from the proxy" as "the proxy minted this": a client that sends its own Authorization and no session cookie reaches the upstream with that header intact. Verify signature, iss, aud and exp against the provider's keys, or introspect — exactly as for a request that never went through a proxy. Header provenance is not an authentication signal.

auth.injection.stripInboundAuthorization (INJECTION_STRIP_INBOUND_AUTHORIZATION) removes the ambiguity at the proxy. When true, an inbound Authorization on a request the proxy did not mint a token for is dropped before the request is forwarded, and logged as injection.inbound_authorization_stripped at warn with the request id and a reason of no_cookie or cookie_rejected. The header value is never logged. Upstream then sees a Bearer header only when the proxy put it there.

It defaults to false — the pass-through behaviour every deployment before the flag ran on — because that behaviour is load-bearing wherever a non-browser client (a service account, a mobile app) deliberately presents its own token through the same proxy. Turn it on when this proxy fronts browser sessions only, and especially when the upstream's authorization has any dependence on where the header came from. It is defence in depth, not a substitute for the paragraph above: nothing stops a client reaching the upstream by another route.

With auth.injection.exchange.enabled the pass-through is gone altogether: an inbound Authorization header is either exchanged for a first-party token or the request is refused, so stripInboundAuthorization has nothing left to strip — see External credential exchange.

Cookie forwarding

Only the cookie named in auth.injection.sessionCookieName is forwarded to the provider on the session grant call. Other cookies (analytics, CSRF tokens, third-party) do not reach the provider.

The upstream never receives the session cookie as a user agent sends it. It is the credential tokens are minted from, and it outlives every token minted from it, so the upstream — and whatever logs or instruments its requests — is handed the token instead. Whenever the proxy forwards a request, every pair named auth.injection.sessionCookieName is removed from Cookie, whether it minted a token or was refused by the grammar check below; the other cookies go on, in order, joined by ; , and the header is dropped when none is left. A Cookie header without the session cookie is forwarded byte for byte. Repeated Cookie header lines are one header by then (Node joins them with ; ), so a session cookie in any of them is removed. There is no setting to forward it: an upstream that needs the user's identity reads the token. Peer token handlers go further: Duende BFF removes the whole Cookie header from a proxied API call, and Curity's OAuth proxy removes cookie headers by default.

The forwarded value must conform to the RFC 6265 section 4.1.1 cookie-value grammar: a run of cookie-octets (printable US-ASCII excluding whitespace, DQUOTE, comma, semicolon, and backslash), optionally wrapped in exactly one surrounding DQUOTE pair. A surrounding DQUOTE pair is accepted and forwarded verbatim (quotes preserved) so the provider's own cookie parser decides how to read it. Anything else is refused — a ,, whitespace, \, a control character, or a non-ASCII byte anywhere in the value, or a DQUOTE anywhere other than as that surrounding pair (interior or unbalanced), or an empty value: the request is forwarded without a minted Authorization and the provider is not called. With the exchange disabled, a client's own Authorization header still reaches the upstream unless stripInboundAuthorization is on; with the exchange enabled, an inbound Authorization is handed to the exchange before the cookie is considered, so it is exchanged or refused rather than passed through (see Inbound Authorization headers). Whitespace immediately after = is part of the value and is refused; SP / HTAB next to the ; separator or at the ends of the header is separator slack and is ignored. ; is the cookie-pair delimiter and never becomes part of a value. Default session stores (express-session connect.sid, hex / base64url / JWT session ids) always conform.

A refused cookie is not silent. A header that does not carry the cookie at all is an ordinary anonymous request and logs injection.no_cookie at debug; a header that carries it in a refused form logs injection.cookie_rejected at warn with the request id and a bounded reason — empty (sid= / sid=""), quoting (a DQUOTE anywhere other than one surrounding pair) or grammar (a character outside cookie-octet). The cookie bytes are never logged. A sustained rate of this event points at a misbehaving client or a provider issuing session cookies outside the grammar.

When the header carries the same name more than once (RFC 6265 section 5.4 lets a user agent send two same-name pairs, ordered by path and then creation time), the first well-formed pair is used. A malformed pair before it is skipped and logged as injection.cookie_rejected with action: "fallback"; only when every same-name pair is malformed is the request forwarded without a minted Authorization, logged with action: "forward" — or action: "forward_stripped" when stripInboundAuthorization is on and there was an inbound header to remove. action is the outcome's own name on both this line and injection.no_cookie, so a query keyed on "forward" alone does not see the stripped requests. sid=bad,val; sid=good and sid=good; sid=bad,val both exchange good.

The cookie name itself is checked at startup: auth.injection.sessionCookieName must be an RFC 6265 cookie-name (an RFC 9110 token: one or more of !#$%&'*+-.^_`|~, digits and letters). A name containing whitespace, = or another separator is a configuration error naming the key, because the name is interpolated into the same outbound Cookie header.

Calling auth.provider's session grant

auth.provider v0.18.0-rc.1 or later refuses a session grant (401, answered here as 401 session_required) unless the browser request it is made for passes its same-origin check, and it takes a request's address and scheme from X-Forwarded-For and X-Forwarded-Proto when the peer sending them — this proxy — is one it trusts. Behind this proxy:

  • The BFF and the provider share one origin. Browsers reach this proxy and the provider at the same origin, the provider's http.public-origin; a load balancer or ingress routes the provider's paths to it and the rest here. Set INJECTION_PUBLIC_ORIGIN to that origin, written scheme://host[:port] with at most one trailing /: only a request over its scheme whose Origin equals it, or that has no Origin and says Sec-Fetch-Site: same-origin, and whose Referer names no other origin, is answered from the token cache (see Cache behavior). Any other request calls the provider and gets its answer, so a top-level navigation or a cross-site request with the session cookie is refused the way the provider refuses it.
  • What this proxy sends. On the session grant, it forwards the browser's Origin, Sec-Fetch-Site and Referer exactly as received, and never fills them in when the browser sent none. On the session grant and the exchange alike, it overwrites X-Forwarded-For and X-Forwarded-Proto with what it saw itself: the address and scheme of its own peer, or, from a peer listed in INJECTION_TRUSTED_PROXIES, what that proxy reported. List the load balancer that terminates TLS in front of this proxy there: otherwise the provider is told http, and with secure cookies it refuses the grant — and this proxy, seeing http where INJECTION_PUBLIC_ORIGIN names https, answers nothing from its session cache.
  • List the addresses of the injection-mode proxies, and nothing else, in the provider's http.trusted-proxies, so the provider believes the X-Forwarded-* this proxy sets. A CIDR is fine only if it covers no validation-mode proxy and no client: from a trusted address that sends no X-Forwarded-For — a validation-mode proxy's introspection, an in-cluster service calling /oauth/token itself, a browser inside that subnet — the provider cannot tell who sent the request and answers 503 on its per-source rate-limited routes (a validation-mode proxy reports that as 502). This proxy sends exactly one address in X-Forwarded-For, which the provider's default http.proxy-hops of 1 covers. A provider that lists a proxy answers 503 on its per-source rate-limited routes, /oauth/token among them, to a request from it that carries no X-Forwarded-For; both token calls carry one. The provider then counts each call against the browser's or client's source, not this proxy's (see Provider rate limiting).
  • Keep the provider's port reachable only through the proxy or the load balancer. Anything that can reach the provider from an address it trusts can tell it any X-Forwarded-For it likes.

External credential exchange (auth.injection.exchange)

Opt-in. With auth.injection.exchange.enabled = true, a request that presents Authorization: Bearer <JWT> instead of a session cookie is exchanged at the provider for a first-party access token, and only that token reaches the upstream. Session-based clients and clients holding a supported external credential then meet the same backend token validation and authorization. Disabled (the default), nothing in this section applies and injection mode behaves exactly as described above.

Responsibility boundary. The proxy is a token-endpoint client. It extracts the credential, submits it to its configured provider, caches a successful result within its validity bounds, and replaces the inbound Authorization header with the issued token. Token validation, issuer trust, identity mapping, exchange authorization and token issuance belong to auth.provider. Its AssertionIssuerRegistry is the source of truth for which external issuers are trusted and on what terms — per-issuer keys, algorithms and allowedSubjects / allowedScopes / allowedAudiences / allowedClients. Adding a tenant's IdP is a provider registration; the proxy does not duplicate that trust configuration and imposes no single-issuer restriction. The unverified iss never selects a token endpoint, a key URL or a client: the proxy always calls its configured providerOrigin.

Grant: RFC 7523, not RFC 8693. The exchange is the RFC 7523 JWT-bearer authorization grant:

POST /oauth/token
Authorization: Basic <client_secret_basic credentials>
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<JWT>[&scope=…][&audience=…][&resource=…]

With a client key instead of a secret, there is no Authorization header, and the body also carries client_id, client_assertion_type and client_assertion.

It is not RFC 8693 token exchange (grant_type=urn:ietf:params:oauth:grant-type:token-exchange with subject_token / subject_token_type), and the two are not interchangeable. The provider accepts only assertions that satisfy the registered issuer's profile, including an audience naming the provider itself: a JWT access token an external IdP issued for some unrelated API is not a valid assertion merely because its issuer is registered. Exchanging general external access tokens needs a separately defined provider validation and exchange contract (RFC 8693) and is not what this path does. Delegation is out of scope as well: the jwt-bearer grant issues no act claim, and no forwarding header stands in for one.

Supported profiles. Whatever the provider's registry admits for the assertion's issuer — plain RFC 7523 assertions, and the Identity Assertion JWT Authorization Grant (ID-JAG) profile. The proxy submits the assertion unchanged; the registry entry decides which profile applies. For ID-JAG the provider enforces two rules the proxy's behaviour is built around:

  • Client binding. The ID-JAG's client_id claim must equal the client that authenticated at the token endpoint — the proxy's auth.injection.exchange.clientId. The IdP must mint ID-JAGs for that client; one minted for any other client is refused (401 credential_rejected).
  • One-time use. Each jti is accepted once. The proxy never resubmits an assertion on its own: it does not retry, it does not cache failures, and concurrent identical requests share one submission. A successful result is reused from the cache until the cache entry expires. After that — or at another proxy instance, whose cache is its own — the same ID-JAG is a replay the provider refuses (401 credential_rejected), and the client must obtain a fresh assertion.
  • The cache window is not replay-checked. The provider's one-time check runs when an assertion is submitted, and a cache hit submits nothing. Within the cache lifetime, whoever presents the same assertion — the client, or anyone who obtained it — is served the cached token. Treat the assertion as a bearer credential for that window; tokenCache.ttlSeconds bounds it.

Client authentication. When the exchange is enabled, clientId and exactly one of clientSecret and clientKey are required, and boot fails otherwise: a client_id alone is not client authentication. With a secret the proxy authenticates with client_secret_basic, using the RFC 6749 §2.3.1 encoding described under Introspection client identity; with a key, with private_key_jwt (see Client authentication with a private key). Register it at the provider as a confidential client whose allowedGrantTypes names urn:ietf:params:oauth:grant-type:jwt-bearer, and list it in allowedClients of every issuer entry that names such a list. The requested scope, audience and resource are fixed by deployment configuration and sent only when set; the provider enforces what they may be, and decides which of them it reads (auth.provider's jwt-bearer grant reads scope, and resource under oauth.resourceIndicator.enabled).

Request source. The exchange carries the same X-Forwarded-For and X-Forwarded-Proto as the session grant (see Calling auth.provider's session grant), so a provider that lists this proxy in http.trusted-proxies places it and counts it against the presenting client's source. It carries no Origin, Sec-Fetch-Site or Referer: its credential is the Authorization header, not a cookie. Concurrent identical requests share one submission, which carries the source of the request that made it.

Request shape. With the exchange enabled:

Session cookie Authorization Result
absent absent Forwarded without Authorization, as without the exchange.
present absent Session grant, as without the exchange.
absent Bearer <JWT> Exchanged; the issued token replaces the header.
absent anything else — another scheme, an opaque token, a malformed JWT, an empty value 401 credential_unsupported. No provider call, nothing forwarded.
present any 400 credential_ambiguous. No provider call, nothing forwarded.

"Present" means the Cookie header carries auth.injection.sessionCookieName, whether or not the value passes the cookie grammar check; other cookies do not count. The proxy never chooses between two credentials and never switches to the other one after one fails. The Bearer scheme is matched case-insensitively, and the token must be a JWS compact JWT whose header and payload decode to JSON objects.

Errors. The session path's shape — { "error", "error_description" }, no WWW-Authenticate. None of these forwards the original credential or falls back to a session.

Status error When
400 credential_ambiguous Session cookie and Authorization on the same request.
401 credential_unsupported Authorization is not Bearer <JWT>.
401 credential_rejected The iss is not in allowedIssuers (no provider call), or the provider answered invalid_grant: bad signature, expired, wrong audience, unregistered issuer, client not admitted by the issuer, replayed ID-JAG, unresolvable subject.
403 exchange_not_permitted The provider answered invalid_scope, invalid_target or unauthorized_client.
502 provider_config_error The provider answered 401 (the proxy's own client authentication), any other 400, or a redirect (redirects are not followed).
502 provider_unavailable Provider 5xx or 429, a network error or timeout before the response arrives, or an unexpected status. The provider's Retry-After is passed through.
502 provider_invalid_response A 200 whose body is not a JSON object (empty, not JSON, or an array), exceeds the 64 KiB bound, or could not be read — a timeout or a dropped connection while reading it; one without an access_token; or one with a token_type other than Bearer (e.g. DPoP).

Each outcome is logged as an injection.exchange_* event (exchange_fetch, exchange_success, exchange_cache_hit, exchange_credential_ambiguous at warn, exchange_credential_unsupported with a reason of scheme or format, exchange_issuer_refused, exchange_rejected, exchange_not_permitted at warn, exchange_provider_config_error / exchange_provider_unavailable / exchange_provider_invalid_response at error, with the provider's error code where there is one). The assertion, the issued token, the client secret or key, the client assertion signed with the key, and the unverified iss are never logged. The provider's error is logged only when it is shaped like an RFC 6749 error code — no whitespace, at most 64 characters, nothing JWT-shaped, not echoing the assertion or secret — and as invalid_error_code otherwise; its error_description is not logged.

Issuer prefilter. allowedIssuers (default empty = off) refuses an assertion whose unverified iss is not listed, before any provider call — a way to shed traffic from issuers the deployment never expects. It is not required for the security of the exchange and never replaces provider validation: a listed issuer is still verified by the provider in full, and an unlisted one gets the same credential_rejected a provider refusal does. From the environment the list is whitespace-separated, like INJECTION_SCOPE.

Cache. A successful exchange is cached in memory, per instance, in a cache separate from the session cache but sized and timed by the same auth.injection.tokenCache settings. Its key is SHA-256 over the grant type, the token endpoint, the client, scope, audience, resource and the assertion, so a result is never reused for a different assertion or exchange context. An entry expires at

min(sent + tokenCache.ttlSeconds, sent + provider expires_in, assertion exp) − tokenCache.safetyMarginSeconds

where sent is the instant the token request went out. ttlSeconds and expires_in are counted from the request, not from the response, so a slow provider cannot push an entry past the issued token's expiry. An assertion without exp, or an entry that would already have expired by the time the response arrives, is not cached. The assertion's exp is read unverified, which is safe because it can only shorten the lifetime. A provider that includes auth.provider#588 also caps a jwt-bearer token's lifetime at the assertion's remaining validity; the proxy's cache bound holds independently of that.

Revocation delay. Expiry bounds do not propagate revocation. Revoking the external credential, its issuer's registration or the proxy's client does not reach a token already issued, which stays valid at offline validators until its own exp (see Revocation and the access-token lifetime), nor a cached result, which this proxy keeps injecting for up to the cache lifetime above without asking the provider again. A deployment that needs immediate revocation needs an explicit mechanism — short issued-token lifetimes and introspection-based validation upstream.

Backends and CSRF. The upstream still verifies every token it is handed — signature, iss, aud and exp against the provider's keys, or introspection — and enforces authorization; a header arriving from this proxy proves nothing on its own. Which internal issuer and audience the issued token carries follows from the provider's configuration, not from the proxy. The session path still needs CSRF protection exactly as described under CSRF responsibility boundary. Every exchange cache miss counts against the provider's /oauth/token rate limit, as a session grant does (see Provider rate limiting).

Threat model — process memory

Active access tokens reside in process memory, and so does a configured client secret or private key, which also sits in the process environment. An attacker with read access to proxy process memory can extract all cached tokens and the proxy's own client credentials. Standard host-security practices apply (container isolation, minimal image, no unnecessary ptrace capabilities).

Graceful shutdown does not clear the caches: the drain closes the listener and lets in-flight requests finish within 10 seconds, a fixed period with no setting (see Shutdown and the grace period) — during the drain a connection closes after its last answer, so a caller that keeps its connection alive does not hold the drain open; a caller that reuses the connection then may see one reset, for a request the proxy never read, which it can retry — after which the remaining connections are force-closed, and the cached tokens stay in memory until the process exits (#95 F21).

Provider rate limiting

auth.provider v0.18 counts requests to its OAuth endpoints per source address: POST /oauth/token under token-endpoint.requests / window (default 60 per 1m), POST /oauth/introspect under token-control.requests / window. It counts an IPv4 source by its address and an IPv6 source by its /64. Which source a call from this proxy counts against depends on whether the provider lists this proxy in http.trusted-proxies:

  • Listed — the setup Calling auth.provider's session grant describes. Injection mode's token calls, session grants and exchanges alike, say in X-Forwarded-For where the request came from, so the provider counts each against the browser's or client's own source. The limit applies per end user's address, or /64, rather than per proxy instance; users behind one NAT, or in one /64, still share one count. Validation mode's introspection calls carry no X-Forwarded-For, and a provider that lists the proxy answers them 503, logging rate-limit.source_undetermined. List only the injection-mode proxies: no validation-mode proxy, and no CIDR that covers one or covers a client, for the same reason.
  • Not listed. Every call counts against this instance's own address. All its session grants and exchanges in injection mode, or all its introspections in validation mode, share one count per instance — not per user, per session or per token. With the default of 60 per minute, one instance is capped at roughly 60 cache-missing requests a minute, however many end users sit behind it. In injection mode the provider then also takes the scheme of every session grant from its own connection to the provider, not from the browser's; where that is plain http and the provider uses secure cookies (an https public origin), it refuses every session grant (see Calling auth.provider's session grant).

Cache hits are free; every miss is a provider call. Concurrent misses on one credential count once: both modes coalesce them into a single provider call, so a burst of parallel requests carrying the same token or cookie costs one. On the session path that holds only for requests the same-origin gate admits; a request it does not admit calls the provider on its own, however many share its cookie.

The overflow is not graceful. The provider answers 429, and the proxy turns that into a 5xx:

  • Injection mode — an unexpected provider 4xx becomes 502 provider_unavailable (the provider's Retry-After is passed through).
  • Validation mode — the provider's 429 becomes 502 Bad Gateway, as every provider failure there does other than a 401 (the token's 401 Invalid Token, or 502 Provider Configuration Error for the proxy's own client) and a redirect (502 Provider Configuration Error).

A provider 503 — a source it could not place, or a rate-limit store that cannot count — becomes a 502 the same way. So the symptom is a burst of proxy 5xx under load, with nothing in it that says "rate limit". Check the provider's rate-limit events before treating it as a provider outage.

Raise the budget on the provider side rather than working around it here:

  • token-endpoint.requests / window for /oauth/token, and token-control.requests / window for /oauth/introspect.
  • The counts live in the provider's store. In memory, each provider replica counts on its own, so a multi-replica provider wants a shared store such as Redis.

Three things that make it worse:

  • Lowering tokenCache.ttlSeconds / INTROSPECT_CACHE_TTL_SEC multiplies the misses. The shorter the TTL, the more of the same budget the same traffic spends. Their effects on revocation differ between injection and validation — see Revocation and the access-token lifetime.

  • Scaling out gives each instance its own cold cache. A rollout therefore costs instance count × active sessions provider calls at once. Where the provider does not list the proxy, each instance also has its own count — unless several sit behind one NAT or egress gateway, present a single source address and share one.

  • Random credentials from one anonymous client spend all of it. This always applies in validation mode, and in injection mode when the provider does not list the proxy.

    • Every call then counts against the instance's own source. Validation's introspection calls never carry X-Forwarded-For. The budget is about 60 cache misses a minute with the provider's defaults.
    • Each random Bearer value, or each random session cookie in injection mode, is a new cache key. A client that sends random ones makes every request a miss and uses up the budget.
    • After that, every legitimate credential not yet in the cache is answered 502 until the window resets.

    What to do now: rate-limit requests that carry Authorization or the session cookie, per client address, at the ingress in front of the proxy. Counting each introspection against its caller is planned (#188), subject to change.

Request body limit

HTTP_BODY_LIMIT_SIZE (default 10mb) bounds the request body in both modes. It is a number, optionally signed + and followed by b, kb, mb, gb, tb or pb in any case, with 1kb = 1024 bytes and any fraction of a byte dropped; any other value fails at boot.

A request whose Content-Length is over the limit is refused before the mode does anything: its token is not introspected and no token is minted for it, so it spends none of the provider's budget. A request that declares no length — a chunked body — can only be measured by reading it. That happens when the request goes upstream, after the mode has checked or minted its token, and nothing reaches the upstream. Both are answered 413, in the mode's refusal shape: { "code": 413, "message": "Payload Too Large" } in validation mode, { "error": "body_too_large", "error_description": "request body over the limit" } in injection mode. The rest of the body is read and discarded, so a keep-alive connection goes on to its next request.

A body within the limit is read whole before it goes upstream, and is sent with a Content-Length whether it arrived with one or chunked; the inbound Transfer-Encoding, and a Trailer announcing fields of it, are not sent on. A request with a transfer coding other than chunked is refused 501, since its body would reach the upstream still coded. The body reaches the upstream as the bytes that arrived: an application/x-www-form-urlencoded body is sent as it came, even one that also parses as JSON, so a signature over the body still verifies and a firewall in front of the upstream reads what the upstream reads. Once the caller has the upstream's answer, the request to the upstream ends: an upstream that answers before it has read the whole body (a 413 on the headers, say) is sent no more of it, and its connection is closed.

A request that sends Expect: 100-continue — curl does, for a large upload — is told 100 Continue unless its declared length is over the limit; a chunked one, which declares none, is measured as it is read. An oversized one is refused 413 on its headers, before a client that waits for the go-ahead sends the body, and the connection is then closed rather than kept alive. The expectation is not forwarded: the proxy has the whole body before it goes upstream.

An upstream the proxy cannot reach — a refused connection, an unknown host, a failed TLS handshake — or one that drops the exchange or answers something that is not HTTP before its answer starts is answered 502, and one that timed out 504 (RFC 9110 §15.6), in the same shape: { "code", "message" } with the status's reason phrase in validation mode, { "error": "upstream_unavailable", "error_description" } in injection mode. It is the upstream failing. The proxy sets no upstream response timeout of its own: an upstream that accepts the connection and never answers holds the request until the caller gives up. A request the upstream never received — refused, unknown host, TLS — can be retried; after a reset, a dropped exchange or a timeout the upstream may already have acted on it, so retry only an idempotent one (RFC 9110 §9.2.2). A caller that hangs up before the upstream answers is not logged as an upstream failure. An upstream that drops the connection once some of its answer has reached the caller cannot be answered 502, since its status is already sent: the proxy closes the caller's connection, so the caller sees the answer end early — a chunked body without its last chunk, or fewer bytes than its Content-Length — and logs the failure the same way, as cut off after it started. One that drops it after its head but before any of the answer reached the caller is still answered 502, with none of the upstream's head. Only an answer that declared no length and ends by closing its connection cannot be told from one cut short: it is forwarded, and ended, as complete.

Whatever else reaches the end of a mode's router unanswered is refused in the same shape, with "error": "request_failed" in injection mode. That includes a body that ended early, a transfer coding other than chunked (501), and anything a mode's middleware threw. The status is the error's own 4xx or 5xx, and 500 otherwise. Once the upstream's answer has started it is streamed to the caller as it arrives, and a failure after that point cannot change its status: the proxy closes the caller's connection and logs it as upstream_unavailable, as above. Each is logged with the request id as requestId:

Event Level When
validation.body_too_large, injection.body_too_large info A body over the limit (413). The line carries limitBytes, and contentLength when the request declared one.
validation.upstream_unavailable, injection.upstream_unavailable error Before its answer started, the upstream could not be reached, dropped the exchange or timed out (502 / 504); or it dropped the connection partway through its answer, and the caller's connection was closed. What the connection threw is under error — for an answer cut off after it started, inside an error that says so.
validation.request_failed, injection.request_failed info for a 4xx, error otherwise Anything else no stage answered, with the error under error: the Error itself in validation mode, its message in injection mode, as each mode logs its other failures.

Fields that stay on the hop to the proxy

The fields that belong to the caller's connection to the proxy are not sent upstream (RFC 9110 §7.6.1): those the inbound Connection names, Keep-Alive, TE, Upgrade and Proxy-Connection. Nor is Proxy-Authorization, a credential for this hop the proxy does not use (§11.7.2). Naming Authorization, x-request-id or Connection itself in Connection does not remove them: what the upstream receives for those is the proxy's to decide — for Authorization, the inbound token in validation mode, and in injection mode the minted one or whatever the mode left in place (see Inbound Authorization headers); for x-request-id, the proxy's request id; for Connection, a connection closed after the one request. Each request goes upstream on a connection of its own, which carries no other request, even when the upstream answers that it would keep it open (RFC 9112 §9.6): no caller's request shares an upstream connection with another's. The upstream request does not go through a forward proxy named in the environment: Node applies NODE_USE_ENV_PROXY with HTTP_PROXY / HTTPS_PROXY through its global agent, which this request does not use.

The upstream's connection fields stay on its hop in the same way. Its response's Connection, the fields that names, Keep-Alive, Proxy-Connection, TE, Upgrade, Trailer and Proxy-Authenticate do not reach the caller. Node keeps or closes the caller's own connection and announces which, so a proxied answer does not close a keep-alive connection. A field the proxy set before forwarding — x-request-id, the CORS fields — stays, with the proxy's value, though the upstream's Connection names it. The answer still streams as it arrives. Where the upstream's answer carries a field the proxy set too, the proxy's value stands: the caller gets the request id the proxy logs under, and the CORS grant the proxy made. Vary lists both instead: the proxy's members, then the upstream's not already listed (compared case-insensitively), or * when either says * (RFC 9110 §12.5.5). While CORS_ORIGIN_PATTERN is set, an upstream's CORS grant never reaches the caller, whether or not the proxy granted the origin; its other Access-Control-* fields, such as Access-Control-Expose-Headers, do. The proxy sends no X-Powered-By of its own; an upstream's reaches the caller.

Via on the upstream request

Every request sent upstream carries Via with the proxy's own member after those the request arrived with (RFC 9110 §7.6.3): the HTTP version the proxy received it by and the pseudonym auth.proxy — Via: 1.1 auth.proxy, or Via: 1.1 lb, 1.1 auth.proxy behind a balancer that sent its own. It names no host or port, and it is always sent; there is no setting. An upstream that takes Via as the sign of a proxied request sees one: nginx, for one, neither compresses answers to such requests nor serves them its pre-compressed files (gzip_static) while gzip_proxied is off, its default, so set gzip_proxied any; on an nginx upstream to keep them compressed. The proxy adds no Via to its answers to the caller; an upstream's own is passed on like any other field.

The caller's Accept-Charset reaches the upstream as it arrived; the proxy sends none of its own.

Where the request came from, on the upstream request

The proxy tells the upstream where each request came from as this hop saw it. A caller cannot choose the address the upstream reads from these fields, and the host they give is the Host the proxy received, the one the hops in front routed on:

Field What the upstream receives
X-Forwarded-For The entries the request arrived with, then the address of the proxy's peer (unknown if the connection has no peer any more), as nginx's $proxy_add_x_forwarded_for does: X-Forwarded-For: 203.0.113.9, 10.0.0.5 behind a balancer at 10.0.0.5 that wrote the client's 203.0.113.9.
X-Real-IP The address of the proxy's peer, in place of the caller's.
X-Forwarded-Host The Host the proxy received, in place of the caller's; none if the request had no Host. The upstream's own Host is the one in UPSTREAM_BASEURL.
Forwarded Nothing: the caller's is not sent on. It carries a host and a scheme as well as an address, and readers take them from its first element, which the caller wrote.
X-Forwarded-Proto What the request arrived with, unchanged. The proxy listens on plain HTTP and cannot see the client's scheme. Only the hop that terminates TLS in front of it can state the scheme.

An upstream should trust only the proxy's hop:

  • Address. Count the proxy as one trusted hop in its X-Forwarded-For handling, plus one for each balancer in front of the proxy that appends to it. For example, Express's trust proxy is 2 behind one balancer. The client is the entry just left of the trusted hops, never the left end. An upstream that trusts by address range rather than by hop count must list the proxy's address too. X-Real-IP is the proxy's peer, which behind a balancer is the balancer. The peer is written as Node reports it. With the default HTTP_HOSTNAME of 0.0.0.0 an IPv4 peer is plain IPv4; on :: it is IPv4-mapped (::ffff:10.0.0.5).
  • Scheme. Have the hop that terminates TLS set X-Forwarded-Proto, replacing any value the client sent; an AWS ALB and ingress-nginx do this by default. Believe it upstream only if clients cannot reach the proxy directly.
  • Host. If a hop in front rewrites Host, have it pass the public host as Host instead; any X-Forwarded-Host it sets is replaced. The client still chooses Host unless a hop in front admits only your public hosts, so an upstream that builds absolute URLs (a password-reset link, a redirect) checks the host against its own list, or uses a configured public origin.
  • Nothing else. Read only the fields in the table above. Any other field of the kind arrives as the caller sent it and is not to be trusted. Examples are X-Client-IP, True-Client-IP, X-Forwarded-Port, X-Forwarded-Prefix, X-Forwarded-Scheme, X-Forwarded-Ssl, and underscore spellings such as X_Forwarded_For that some servers read as the hyphenated field.
  • Connection. As for every field, those the inbound Connection names are not sent on (see Fields that stay on the hop to the proxy). The proxy's own entries are added after that. A hop in front that passed the client's Connection on would therefore let it remove the X-Forwarded-For entries and the X-Forwarded-Proto that hop wrote. A compliant proxy does not pass it on (RFC 9110 §7.6.1); nginx, for one, sends its own.

Request id (x-request-id)

Every request gets one id, which every log line about it carries as requestId, which the proxy sends upstream and to the provider as x-request-id, and which it echoes to the caller on the answer. The caller's own x-request-id is reused when it is 1 to 200 characters drawn from letters, digits and - . _ ~ + / = — enough for a UUID, a ULID, base64 or base64url, or a W3C trace id. Any other value is replaced by a fresh id, as a missing one is: one longer than 200 characters, or one with whitespace, a control character, a quote, a backslash, ,, ;, any other punctuation or a non-ASCII character. Two x-request-id header lines are replaced too, since Node joins them with , . The request is not refused, and the replaced value is not logged; the caller learns the id in use from the answer's x-request-id. 200 is the bound Heroku's router applies to the same header.

Client authentication with a private key (private_key_jwt)

Wherever the proxy authenticates to the provider as a client, it can hold a private key instead of a shared secret (RFC 7523 §2.2): introspection in validation mode, the exchange, and the session grant.

Call Key Secret, the alternative Neither
Introspection CLIENT_KEY CLIENT_SECRET the inbound token is the credential
Session grant INJECTION_CLIENT_KEY — a public client: client_id alone
Exchange INJECTION_EXCHANGE_CLIENT_KEY INJECTION_EXCHANGE_CLIENT_SECRET refused at boot

A key and a secret for the same client are refused at boot. With a key, every call signs a new client assertion and sends it in the form body with the client_id, and no Authorization header — the provider refuses a request that authenticates two ways:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=…&client_id=<client id>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<JWT>

The assertion's iss and sub are the client id, its only aud is the provider's issuer identifier, and it carries a random jti and an exp 60 seconds after its iat. Its header names typ: client-authentication+jwt, so it cannot be taken for another kind of JWT signed with the same key. The provider accepts each jti once, and the proxy never reuses one.

The provider's issuer. VALIDATION_PROVIDER_ISSUER or INJECTION_PROVIDER_ISSUER is required with a key, and refused without one (an issuer no key uses is a key that did not arrive). Copy it from the issuer of the provider's discovery document (/.well-known/openid-configuration), character for character: https (http only on a loopback host), a path allowed, printable ASCII (if the provider's issuer has a Unicode host, configure the provider with its xn-- form), no query, fragment, userinfo or whitespace. Unsetting the issuer is part of turning off the last key, including disabling an exchange that holds one. It is not derived from INTROSPECT_URL or INJECTION_PROVIDER_ORIGIN, since the URL the proxy reaches the provider at can differ from the issuer the provider names itself by. The issuer alone is the audience, as recommended for private_key_jwt, rather than the token endpoint URL RFC 7523 also allows.

The key. A private JWK, as JSON text in the environment variable: an Ed25519 key (EdDSA), an EC P-256, P-384 or P-521 key (ES256, ES384, ES512), or an RSA key of 2048 bits or more (RS256 unless the JWK's alg names RS384, RS512 or a PS*). An alg, if the JWK has one, must be the JWS name above — EdDSA, not RFC 9864's Ed25519 — since the provider matches a registered key's alg to the header's exactly. A use other than sig, or key_ops without sign, is refused, and so is a JWK whose public members are not its private key's. Its kid, when present, goes in every assertion's header, so the provider picks the matching public key. Anything else — a public key, a symmetric key, an algorithm that does not fit the key — stops the process at boot, naming the key and never printing it. The proxy logs neither the key nor an assertion. Supply the key from the environment (or a secret store that sets it), not by writing it into application.conf: the image build copies config/, so a key written there ends up in an image layer.

For example, an Ed25519 key generated with Node and the public half to register:

node -e 'const { generateKeyPairSync } = require("node:crypto");
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const kid = "proxy-2026-09";
console.log("CLIENT_KEY=" + JSON.stringify({ ...privateKey.export({ format: "jwk" }), kid }));
console.log("public JWK: " + JSON.stringify({ ...publicKey.export({ format: "jwk" }), kid }));'

At the provider (auth.provider: the oauth package's README, "Client authentication: private_key_jwt"), register the client with tokenEndpointAuthMethod: "private_key_jwt", no clientSecret, and its public key — inline as jwks, or at a jwksUri the provider fetches. The public JWK is the private one without its private members (d, and an RSA key's p, q, dp, dq, qi), keeping its kid; its alg, if any, EdDSA/ES*/RS*/PS* as signed, and its key_ops, if any, ["verify"]. The provider records each jti in its replay seen-set, which a composition must wire for private_key_jwt to be accepted at all: without one, the provider answers a client assertion 500 server_error, which the proxy reports as a provider failure (502 Bad Gateway in validation, 502 provider_unavailable in injection), not as a configuration error. A session-grant client that authenticates with a key is a confidential client: register it as one.

Switching an existing client to a key. The provider allows one authentication method per client (tokenEndpointAuthMethod), so changing an existing client from a secret, or from a public client, to private_key_jwt in place is a hard cutover: every instance still sending the old credential is refused from that moment. Instead, register a new client id with private_key_jwt and the same scopes, grant types and allowedAudiences, roll the proxy to the new client id and key, then retire the old client. What depends on the client id, and so needs carrying over:

  • validation's audience pin is the calling client's id and its allowedAudiences — if tokens name the old client id as aud, list it in the new client's allowedAudiences;
  • a token from the session grant or the exchange (absent a resource) has aud = the client's first allowedAudiences entry, else its client id — if the old client had none, give the new one the old client id as its first entry;
  • a token's azp — from the session grant or the exchange — is the client id, and an exchanged token also carries it as client_id, so an upstream that pins either must accept the new one, and a provider grant policy that names the client must name the new one;
  • an issuer entry at the provider that names allowedClients must list the new exchange client, or every exchange is refused.

Rotating the key. The proxy signs with one key at a time; rotation happens where keys are verified. Publish the new public key beside the old one in the client's jwks or jwksUri, switch the proxy's key (with a new kid), and remove the old public key once no instance signs with it. Instances mid-rollout keep working, since the provider picks the key by kid — so give every key a kid: with two public keys of the same type published, an assertion without one matches both and is refused, which makes rotating away from a key without a kid a cutover.

Clocks. An assertion is valid from its iat for 60 seconds, and the provider allows its clock tolerance (30 seconds by default) on top. A proxy whose clock runs far enough behind or ahead has its assertions refused: validation answers 502 Provider Configuration Error, and injection 502 provider_config_error, as for any refused client authentication.

Setup

pnpm install
pnpm run build
pnpm run start

Development

pnpm run debug    # tsx watch mode

Docker

make docker       # Build runtime image

The runtime image (--target runtime):

  • Runs as the unprivileged node user (uid 1000). Its files, and /home/node that holds them, belong to root and are only read: the process cannot replace its own code or configuration. The image also works with a read-only root filesystem, which the operator turns on (docker run --read-only, Kubernetes readOnlyRootFilesystem: true).
  • Listens on port 8080. The image sets HTTP_PORT=8080, a port a non-root process can bind; the configuration's own default, 80, applies outside the image. Publish it with -p <host-port>:8080, or set HTTP_PORT.
  • Runs node dist/src/app.mjs as PID 1, with no package manager in between: SIGTERM reaches the proxy and starts the graceful shutdown, and stdout carries only the proxy's NDJSON lines. The image holds no pnpm (the base image's npm and corepack are present but never run), and starts without network access.
  • Declares a HEALTHCHECK that asks GET /_healthcheck on 127.0.0.1:${HTTP_PORT} with the base image's busybox wget. It is a liveness probe: it says nothing about the provider or the upstream. If HTTP_HOSTNAME names an address that 127.0.0.1 does not reach, override the healthcheck — localhost among them, which resolves to ::1 inside the image. Kubernetes ignores HEALTHCHECK; give the pod its own probe on the same path.
  • Ships no tests: the build compiles tsconfig.build.json, which leaves __tests__ out of dist/.

Building needs Docker Engine 25 or later (the HEALTHCHECK uses --start-interval). CI builds the image on every push and pull request and runs it through .github/scripts/image-smoke.sh, which checks each of the above.

The base image is named by Node minor in the Dockerfile's FROM lines (node:<major>.<minor>-alpine). Until Node publishes the next minor, the tag follows this minor's newest patch and Alpine packages: build with --pull, as make docker does, so that a rebuild takes them rather than an image the host already holds. After that the tag no longer changes: a fix reaches the image only through a change to those lines, which Dependabot proposes each week, and rebuilding an earlier release of the proxy keeps that release's minor. Without --pull, a host that already holds the tag builds on the patch it pulled last, which can predate a security release.

Configuration

A value the proxy cannot use as written — an empty or negative number, a URL with a path where an origin is required, a pattern that does not compile — stops it at boot, before it listens, with an error naming the key. A number is written as decimal digits.

Shared environment variables:

Environment Variable Description
AUTH_MODE Required. "validation" or "injection".
HTTP_PORT HTTP listen port, an integer from 1 to 65535 (default: 80; the Docker image sets 8080, see Docker).
HTTP_HOSTNAME HTTP listen hostname, not empty (default: 0.0.0.0).
HTTP_PATH_PREFIX Path prefix for proxy routes (default: /): /, or /-separated segments of letters, digits, -, ., _ and ~ (/api, /api/v1), starting with /; a trailing / mounts the same as none. A request outside it, other than the healthcheck, is answered 404 with the text Not Found, without reading its body. CORS applies under it only.
HTTP_BODY_LIMIT_SIZE Request body size limit (default: 10mb). A number, optionally signed +, with an optional unit — b, kb, mb, gb, tb, pb; 1kb = 1024 bytes — or the proxy does not start. See Request body limit.
UPSTREAM_BASEURL Upstream service origin — scheme://host[:port], http or https, at most one trailing /, no path/query/fragment/userinfo (default: http://localhost:3000, for development only: always set it, see Operations). The proxy forwards to its host and port only, so a path would be dropped.
CORS_ORIGIN_PATTERN CORS origin regex pattern (optional). When set, the proxy answers CORS under HTTP_PATH_PREFIX: an origin the pattern matches is granted, with credentials (Access-Control-Allow-Origin set to it, Access-Control-Allow-Credentials: true); any other origin, and a request without one, is granted nothing — the proxy sets no Access-Control-* field on its answer, on a preflight or otherwise, and on a proxied answer the upstream's grant is dropped as below. A pattern that does not compile stops the proxy at boot. Anchor the pattern (^…$): an origin it matches is granted, and that includes null, the opaque origin of a sandboxed frame or a file: page, should the pattern match the text null. At boot the proxy warns, and still uses the pattern, when a top-level alternative is not anchored as ^…$ (cors.origin_pattern_unanchored), when the pattern admits null (cors.origin_pattern_admits_null) or an origin no deployment serves, https://attacker.invalid or http://attacker.invalid (cors.origin_pattern_admits_foreign_origin), when an unescaped . stands before a host name character (cors.origin_pattern_unescaped_dot), or when a wildcard runs straight into a host name, as .*example\.com admits evilexample.com (cors.origin_pattern_unbounded_wildcard); the check is syntactic, so it can miss a broad pattern and flag a narrow one. Every answer there carries Origin in Vary (or Vary: * from the upstream), but one to an OPTIONS that is not a preflight. A preflight — OPTIONS with Origin and Access-Control-Request-Method — is answered 204 before the mode, and is neither forwarded nor logged; any other OPTIONS is an ordinary request. The grant is the proxy's alone: an upstream's Access-Control-Allow-Origin and Access-Control-Allow-Credentials never reach the caller. Unset or empty, the proxy does no CORS and passes the upstream's on.
LOG_LEVEL pino log level — trace, debug, info, warn, error, fatal or silent (default: info). Read directly from the environment when the logger is created, not through the HOCON config. Output is NDJSON on stdout. In both modes, every line about a request — incoming request and each decision — carries the request id as requestId and an event named injection.* or validation.*.
DEBUG Not a setting of the proxy: read by the debug package its libraries use, and written to stderr outside the logger. Leave it unset. DEBUG=express-http-proxy prints live credentials; see Logs and DEBUG.

Validation mode:

Environment Variable Description
CLIENT_ID Client ID for introspection auth (optional; set with exactly one of CLIENT_SECRET and CLIENT_KEY).
CLIENT_SECRET Client secret for introspection auth (client_secret_basic; optional, with CLIENT_ID).
CLIENT_KEY Private JWK for introspection auth (private_key_jwt; optional, with CLIENT_ID and VALIDATION_PROVIDER_ISSUER). See Client authentication with a private key.
VALIDATION_PROVIDER_ISSUER The provider's issuer identifier, the client assertion's audience. Required with CLIENT_KEY, and refused without it.
INTROSPECT_URL Introspection endpoint URL (default: http://localhost:3000/oauth/introspect, for development only: always set it, see Operations): absolute http(s), without userinfo. Anything else is refused at boot, naming the key — the proxy authenticates with its client credentials, never with the URL.
VALIDATION_REALM RFC 6750 realm for WWW-Authenticate (optional; at most 256 printable ASCII characters, without ", \ or surrounding spaces). Unset, a request using another auth scheme gets no challenge.
INTROSPECT_CACHE_TTL_SEC Cache TTL in seconds, an integer of at least 0; 0 turns the cache off (default: 30).
INTROSPECT_CACHE_MAX_ENTRIES Cache max entries, at least 1 (default: 10000).
INTROSPECT_TIMEOUT_MS Introspection HTTP timeout in milliseconds, from 1 to 2147483647 (default: 5000).

Injection mode:

Environment Variable Description
INJECTION_PROVIDER_ORIGIN Provider origin — scheme://host[:port], no path/query/fragment, no userinfo, http or https only (default: http://localhost:3000, for development only: always set it, see Operations).
INJECTION_CLIENT_ID Required. OAuth client_id.
INJECTION_CLIENT_KEY Private JWK for the session grant (private_key_jwt; optional). Unset, the session grant is a public client.
INJECTION_PROVIDER_ISSUER The provider's issuer identifier, the client assertion's audience. Required with INJECTION_CLIENT_KEY or an enabled exchange's INJECTION_EXCHANGE_CLIENT_KEY, and refused without either.
INJECTION_SCOPE Required. OAuth scope string (space-separated).
INJECTION_SESSION_COOKIE_NAME Session cookie name (default: connect.sid). Must be an RFC 6265 cookie-name (RFC 9110 token) — whitespace, = or other separators fail at startup.
INJECTION_STRIP_INBOUND_AUTHORIZATION "true" / "false" (default: false). Drop an inbound Authorization header on a request the proxy did not mint a token for. Any other value fails at startup. See Inbound Authorization headers.
INJECTION_PUBLIC_ORIGIN The origin browsers reach this proxy at, written scheme://host[:port] with http or https and at most one trailing / (optional). Anything else — a path, a query, a fragment or userinfo, even an empty one or one that would normalise away like /a/.. — and the proxy does not start. It is held as a browser sends Origin: scheme and host lower-cased, the default port and the trailing / dropped. A request over its scheme whose Origin equals it may be answered from the session token cache. Unset, only a request over https without Origin or a Referer naming an origin, that says Sec-Fetch-Site: same-origin, can be — browsers send a same-origin Referer by default, so set it, or the session cache is effectively off; startup then logs injection.public_origin_unset at warn. See Cache behavior and Calling auth.provider's session grant.
INJECTION_TRUSTED_PROXIES The proxies in front of this one whose X-Forwarded-For and X-Forwarded-Proto it believes when it tells the provider, on the session grant and the exchange, where a request came from: a whitespace-separated list of IP addresses and address/prefix ranges (default: empty = none; the socket's peer and scheme are sent). A port, an IPv6 zone ID or a host name fails at startup. An entry that on its own trusts every IPv4 address or every IPv6 address — 0.0.0.0/0, ::/0, or an IPv6 range holding ::ffff:0:0/96, such as ::ffff:0:0/96 itself — trusts every client's headers, and starts with a warning (injection.trusted_proxies_trust_all); several entries that cover every address only together are not detected. List the load balancer or ingress that terminates TLS in front of this proxy, or the provider sees http and the session cache answers nothing where INJECTION_PUBLIC_ORIGIN is https. See Calling auth.provider's session grant.
INJECTION_TOKEN_CACHE_TTL_SEC Token cache TTL in seconds, at least 1 (default: 60). Applies to the session and exchange caches alike.
INJECTION_TOKEN_CACHE_MAX_ENTRIES Token cache max entries, at least 1 (default: 10000), for each of the session and exchange caches.
INJECTION_TOKEN_CACHE_SAFETY_MARGIN_SEC Clock-drift safety margin in seconds, at least 0 and less than the TTL (default: 5). Applies to both caches.
INJECTION_TIMEOUT_MS Provider HTTP timeout in milliseconds, from 1 to 2147483647 (default: 5000), for session grants and exchanges.
INJECTION_EXCHANGE_ENABLED "true" / "false" (default: false). Exchange an inbound Authorization: Bearer <JWT> at the provider (RFC 7523 jwt-bearer). Any other value fails at startup. See External credential exchange.
INJECTION_EXCHANGE_CLIENT_ID client_id the proxy authenticates as for the exchange. Required when the exchange is enabled.
INJECTION_EXCHANGE_CLIENT_SECRET Its client secret (client_secret_basic). When the exchange is enabled, exactly one of this and INJECTION_EXCHANGE_CLIENT_KEY is required.
INJECTION_EXCHANGE_CLIENT_KEY Its private JWK (private_key_jwt), instead of the secret; needs INJECTION_PROVIDER_ISSUER.
INJECTION_EXCHANGE_SCOPE scope sent with the exchange (space-separated; optional).
INJECTION_EXCHANGE_AUDIENCE audience sent with the exchange (optional).
INJECTION_EXCHANGE_RESOURCE RFC 8707 resource sent with the exchange (optional).
INJECTION_EXCHANGE_ALLOWED_ISSUERS Optional prefilter on the unverified iss: a whitespace-separated list (default: empty = off).

Operations

What a deployment needs beyond the settings above. For the image itself, see Docker.

Always set the provider and upstream URLs

The shipped config/application.conf gives these keys development defaults:

Variable Default
UPSTREAM_BASEURL http://localhost:3000
INTROSPECT_URL http://localhost:3000/oauth/introspect
INJECTION_PROVIDER_ORIGIN http://localhost:3000

The defaults suit a provider and an upstream on a developer's machine. Inside a container, localhost is the container (or pod) itself, so a deployment that leaves them unset sends its traffic to the proxy itself or to nothing. Set every one the mode uses, in every deployment.

The configuration file

The entry point, dist/src/app.mjs, reads ../config/application.conf relative to itself. In the image that is /home/node/dist/config/application.conf.

  • To replace the file, mount over that path.
  • A file mounted at /home/node/config/application.conf, where the source tree keeps it, is never read.
  • A replacement file is the whole configuration. Keep the shipped file's key = ${?ENV} lines for every key you still want to set from the environment.
  • The mounted file must be readable by the image's user.

Liveness probe

GET /_healthcheck answers 200 {"status":"ok"}.

  • It is mounted at the root, outside HTTP_PATH_PREFIX.
  • It is never authenticated or forwarded, and carries no request id.
  • It is a liveness probe: it answers while the provider or the upstream is down, so do not use it as readiness.

Shutdown and the grace period

On SIGTERM or SIGINT the proxy stops accepting connections and gives in-flight requests 10 seconds to finish. The period is fixed in src/app.mts; there is no setting for it.

  • A clean drain exits 0.
  • Past the 10 seconds, the remaining connections are cut and the process exits 1.

Keep the orchestrator's grace period above 10 s, so the proxy finishes its drain before SIGKILL:

  • Kubernetes terminationGracePeriodSeconds defaults to 30 s, which is enough. The proxy refuses new connections as soon as SIGTERM arrives, while the pod may still receive traffic until its endpoint is removed; a short preStop sleep covers that gap.
  • compose stop_grace_period and docker stop default to 10 s, the same as the drain, which leaves no margin. Raise it, for example stop_grace_period: 15s.

A request that runs longer than 10 s at shutdown, such as a long download or a server-sent event stream, is cut.

Memory: request bodies are buffered

A request the mode forwards has its body read whole into memory before it goes upstream, up to HTTP_BODY_LIMIT_SIZE (default 10 MB). In injection mode that includes requests without a session cookie. Answers are streamed, not buffered.

Peak memory is therefore roughly the number of concurrent forwarded requests with a body × HTTP_BODY_LIMIT_SIZE: 100 concurrent 10 MB uploads hold about 1 GB. To keep it bounded:

  • size the container's memory limit for the concurrency you admit;
  • lower HTTP_BODY_LIMIT_SIZE to what the upstream really accepts;
  • cap concurrency at the ingress.

Keep-alive behind a load balancer

The proxy keeps a caller's connection open between requests and closes it once it has been idle for about 5 seconds, advertised as Keep-Alive: timeout=5. That is Node's default keep-alive timeout, and the proxy has no setting for it.

A load balancer that pools connections to the proxy and keeps one idle for longer can send a request just as the proxy closes that connection, and answers that request 502. What to do depends on the balancer:

  • nginx: set keepalive_timeout in the upstream block (default 60 s) below 5 s. It only closes idle pooled connections, so lowering it is safe.
  • A balancer whose one idle timeout also bounds a request in flight, such as AWS ALB (default 60 s): lowering it below 5 s fails every request that is silent for that long with 504. There is no safe setting today. Expect an occasional 502 on a reused connection, which a retry of an idempotent request absorbs. A keep-alive setting on the proxy, to set above the balancer's timeout, is planned (#197), subject to change.

CORS preflight answers

With CORS_ORIGIN_PATTERN set, a preflight from an origin the pattern admits is answered 204 with:

  • Access-Control-Allow-Origin set to that origin, and Access-Control-Allow-Credentials: true;
  • Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE, a fixed list;
  • Access-Control-Allow-Headers echoing the preflight's Access-Control-Request-Headers. Every header is allowed for an admitted origin, so the pattern is the only gate;
  • no Access-Control-Max-Age.

Without Max-Age, browsers cache a preflight result for 5 seconds only, the Fetch standard's default. A page that keeps sending non-simple requests therefore preflights again every few seconds. The proxy answers each preflight itself, without reaching the provider or the upstream, but each one is a round trip for the browser.

Logs and DEBUG

The proxy's own log lines (NDJSON on stdout) never carry a token, a cookie, a client secret or a key. Errors pass through an allowlist (src/logger.mts), and the description of each event in this README names what it leaves out.

The DEBUG environment variable is outside that guarantee. It turns on the debug package that express-http-proxy and Express use, which writes to stderr unfiltered. DEBUG=express-http-proxy (or DEBUG=*) prints the full outbound request options of every proxied request. That includes the Authorization header (the caller's token or the token the proxy minted) and the cookies.

  • Never set DEBUG in a deployment that handles real credentials.
  • Treat the stderr of one that did as holding secrets.

Related Projects

License

Apache License 2.0

About

Auth proxy — token validation and caching reverse proxy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages