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.
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.
Validates inbound Authorization: Bearer <token> headers against the provider's introspection endpoint. Requests without a Bearer header are forwarded unchanged (public endpoints remain reachable).
Flow:
- Detects
Authorization: Bearer <token>header (passes through if absent). - Checks in-memory cache keyed by SHA-256 of the token.
- On cache miss, calls provider's
POST /oauth/introspect. - Returns
401ifactive: false; forwards the request ifactive: true.
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.
CLIENT_ID / CLIENT_SECRET are optional, and the choice between setting them and leaving them unset changes which tokens the provider will call active.
With client authentication (both set). The proxy authenticates to POST /oauth/introspect with HTTP Basic, and the provider then pins the introspected token's audience to the calling client's own identity: a token whose aud does not name this client 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 some unrelated client_id 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.
A companion change in
auth.providerwidens this pin toallowedAudiences ∪ {clientId}, the ceiling its other grants already use. Until it lands, only a token whoseaudis exactly the caller'sclient_idintrospects as active under client authentication.
Without client authentication (both 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
audfor you. It records the gap and returns the claims. The proxy does not checkaudeither, so a token minted for a different resource server validates here. Unless the upstream service checksauditself, that is a confused-deputy gap — setCLIENT_ID/CLIENT_SECRETand register the audience, or checkaudupstream. - 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. buildAuthHeader does this (pctEncode, src/modes/validation/introspect.mts), 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.
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:
- Extracts the session cookie named in
auth.injection.sessionCookieName. - 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.
- On cache hit, injects the cached Bearer and forwards.
- On cache miss, exchanges the cookie for an access token via the provider's
POST /oauth/tokenwithgrant_type=session. Concurrent misses on the same cookie coalesce into a single provider call (single-flight). - Injects
Authorization: Bearer <token>and forwards upstream.
- 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. Default 60s minus 5s safety margin. - Every cache miss spends from a rate-limit bucket the whole instance shares — see Provider rate limiting before lowering the TTL.
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.expiresInbounds 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,audandexpare 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 — callingPOST /oauth/introspectasks the provider on every cache miss, so a token the provider has stopped vouching for comes backactive: falsewithin 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 asks the provider about the tracked session. This setting belongs to validation mode; it does not change the injection proxy's token cache or an offline validator's behavior.
One proxy instance serves one OAuth scope domain. auth.injection.clientId and auth.injection.scope are fixed at deploy time. 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.
The proxy is a transparent augmentation layer. It injects the Bearer but does NOT enforce CSRF. Combine with SameSite=Lax cookies, same-origin deployment, and CSRF protection in the upstream service. Being transparent cuts the other way too — see 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). 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.
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 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 Authorization and the provider is not called. 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 anonymously, logged with action: "forward". 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.
Active access tokens reside in process memory. An attacker with read access to proxy process memory can extract all cached tokens. Standard host-security practices apply (container isolation, minimal image, no unnecessary ptrace capabilities).
The provider rate-limits its OAuth endpoints on the caller's IP address — the bucket key is <endpoint>:ip:<ip>. Every call this proxy makes shares one bucket per proxy instance: POST /oauth/token in injection mode, POST /oauth/introspect in validation mode. Not per user, not per session, not per token.
With the provider's default budget of 60 requests per 60s, one proxy instance is capped at roughly 60 cache-missing requests a minute, however many end users sit behind it. Cache hits are free; every miss spends from the shared bucket.
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'sRetry-Afteris passed through). - Validation mode — a non-401 introspection failure becomes
500 Internal Server Error.
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:
memoryRateLimiter.limits { token { limit, windowSeconds } }and{ introspect { … } }for the single-process memory adapter.redisRateLimiter.limits { … }(andredisRateLimiter.defaultLimit) whenrateLimiter.adapter = "redis"— which a multi-replica deployment needs anyway, since the memory adapter's counters fork per replica.
Two things that make it worse:
- Lowering
tokenCache.ttlSeconds/INTROSPECT_CACHE_TTL_SECmultiplies the misses. The shorter the TTL, the more of the same 60/60s bucket 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 bucket and its own cold cache. A rollout therefore costs
instance count × active sessionsprovider calls at exactly the moment the buckets are being spent fastest. If several instances sit behind one NAT or egress gateway they present a single source IP and share one bucket instead.
pnpm install
pnpm run build
pnpm run startpnpm run debug # tsx watch modemake docker # Build runtime imageShared environment variables:
| Environment Variable | Description |
|---|---|
AUTH_MODE |
Required. "validation" or "injection". |
HTTP_PORT |
HTTP listen port (default: 80). |
HTTP_HOSTNAME |
HTTP listen hostname (default: 0.0.0.0). |
HTTP_PATH_PREFIX |
Path prefix for proxy routes (default: /). |
HTTP_BODY_LIMIT_SIZE |
Request body size limit (default: 10mb). |
UPSTREAM_BASEURL |
Upstream service base URL. |
CORS_ORIGIN_PATTERN |
CORS origin regex pattern (optional). |
Validation mode:
| Environment Variable | Description |
|---|---|
CLIENT_ID |
Client ID for introspection auth (optional; must be set together with CLIENT_SECRET). |
CLIENT_SECRET |
Client secret for introspection auth (optional; must be set together with CLIENT_ID). |
INTROSPECT_URL |
Introspection endpoint URL. |
INTROSPECT_CACHE_TTL_SEC |
Cache TTL in seconds (default: 30). |
INTROSPECT_CACHE_MAX_ENTRIES |
Cache max entries (default: 10000). |
INTROSPECT_TIMEOUT_MS |
Introspection HTTP timeout (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). |
INJECTION_CLIENT_ID |
Required. OAuth client_id. |
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_TOKEN_CACHE_TTL_SEC |
Token cache TTL in seconds (default: 60). |
INJECTION_TOKEN_CACHE_MAX_ENTRIES |
Token cache max entries (default: 10000). |
INJECTION_TOKEN_CACHE_SAFETY_MARGIN_SEC |
Clock-drift safety margin in seconds (default: 5). |
INJECTION_TIMEOUT_MS |
Provider HTTP timeout (default: 5000). |
- auth.provider — OAuth 2.0 token issuance.
- auth.policy-verifier — No-DSL ABAC policy verifier.
- auth — Architecture docs and cross-component E2E tests.
- protobuf.interceptors — protobuf-option-driven authorization interceptors for gRPC / ConnectRPC.
Apache License 2.0