Blog / 2025-08-01

Centralizing Session Checks with an NGINX Auth Proxy

Harsh Vardhan Goswami

Mixed-language systems tend to repeat session handling. A Next.js application may understand an Auth.js cookie, while a Go service, a Python API, and an older internal tool each need a different library or middleware. Reimplementing token parsing in every service creates inconsistent expiry checks, different error behavior, and more places where a caller-supplied identity header can be trusted by mistake.

An NGINX authentication boundary can centralize the session check before a request reaches those services. This repository's implementation uses OpenResty Lua to validate Auth.js JWE session cookies through an internal verification endpoint. It is a boundary for authentication, not a complete authorization system and not evidence of compliance with any standard.

Authentication is not authorization

Authentication answers who presented a valid session. In this implementation, a successful check produces a trusted X-User-Id header from the JWE sub claim. Authorization answers whether that identity may perform a particular action on a particular resource. The upstream application or a dedicated policy service must still make that decision.

For example, a proxy may establish that a request belongs to user u_123. It does not by itself decide whether u_123 may read a billing record, execute a query, change a team setting, or act for a tenant. Do not replace application-level tenant checks with an identity header alone.

This distinction also affects rollout decisions. Put only services that can safely consume the proxy's trusted headers behind the boundary. A service that remains reachable directly can receive spoofed headers unless it independently rejects them or network policy blocks that path.

Request flow in this implementation

The normal protected path is deliberately short:

client request with Auth.js session cookie
  -> OpenResty access phase
  -> internal /verify-jwe subrequest
  -> JWE decrypt service validates the token
  -> Lua checks sub, exp, and optional issuer/audience claims
  -> proxy sets X-User-Id and forwards to the upstream service

The /verify-jwe location is marked internal, so an external client cannot use it as a public decrypt endpoint. Before examining exceptions or tokens, the Lua code clears caller-supplied identity, credential, routing, and bypass headers. After validation it sets only the user ID derived from the verified subject claim. NGINX then establishes its own forwarding headers for the upstream request.

Missing, invalid, expired, or claim-mismatched tokens return 401. Missing proxy configuration, an unavailable verification service, or an unexpected failure return 503. That is fail-closed behavior for the protected locations: the proxy does not forward a request after an authentication failure.

JWE validation and token handling

The companion decrypt service uses jose to decrypt Auth.js-issued JWE tokens. Its current implementation accepts the dir key-management algorithm and A256GCM or A256CBC-HS512 content encryption. The Lua layer requires a non-empty string sub and a numeric, unexpired exp. If configured, TOKEN_ISSUER and TOKEN_AUDIENCE must match the token claims.

Cookie names and token derivation are configuration-sensitive. Here, JWT_SALT names the Auth.js session cookie and also participates in the decrypt service's key derivation; it defaults to authjs.session-token. A deployment should set JWT_SECRET through its secret manager, never in an image, a committed environment file, a shell history entry, or a documentation example.

Cookie flags are set by the application that issues the cookie, not by this Lua code. For HTTPS browser sessions, review Secure, HttpOnly, SameSite, domain, and path settings in the issuer. Also review token lifetime and refresh behavior. A proxy can reject an expired token, but it cannot compensate for an overly broad cookie domain or a leaked browser session.

Cache and database checks are design choices

The NGINX configuration still declares Redis and PostgreSQL-related environment variables. They reflect a design where a proxy might cache user-validation results in Redis and consult PostgreSQL when the cache misses. Caching can reduce repeated database lookups, but it creates a staleness window: a disabled user or changed membership can remain accepted until the cache entry expires unless invalidation is designed and tested.

That database-backed verifier is not implemented in the current authentication path. ENABLE_DB_CHECK must remain false; setting it to true causes the proxy to return 503 because no verifier is configured. Redis and PostgreSQL settings should not be treated as active access controls merely because their names exist in NGINX configuration.

If a future deployment adds that verifier, document the cache key, TTL, invalidation behavior, outage policy, and source of truth. Test revocation at cache-hit and cache-miss boundaries. A cache policy is part of authorization freshness, not just performance tuning.

Exceptions and bypasses need a narrow boundary

This implementation has one exact unauthenticated application path: /slack/events. Slack signature validation remains the downstream service's responsibility. The exact match matters: /slack/events/ and other paths do not inherit the exception.

There is also an opt-in local bypass. It is accepted only when ALLOW_LOCAL_BYPASS=true, the supplied header exactly matches a non-empty LOCAL_BYPASS_VALUE, and the direct peer address is loopback. It does not set an identity header. Keep it disabled outside direct local development. A reverse proxy, container network, or load balancer can change what the application sees as the peer address, so test the real deployment topology rather than assuming a loopback check has the intended boundary.

Avoid broad location patterns, proxy-side allowlists that silently skip authentication, and routes exposed on an upstream port. Each is a potential bypass path. Inventory every hostname and listener during deployment, then verify that protected backends accept traffic only from the proxy or independently authenticate requests.

Logging without leaking sessions

Log outcomes that help operators answer what happened: request ID, route class, upstream, status code, verification-service availability, and a privacy-reviewed user identifier where appropriate. Do not log cookie values, JWE payloads, JWT_SECRET, bypass values, or authorization headers. Error logs should distinguish an invalid session from an unavailable verifier without echoing token material.

Monitoring should treat 401 and 503 differently. A rise in 401 can indicate expired sessions, a cookie configuration problem, or a client integration change. A rise in 503 can indicate missing configuration, an unhealthy decrypt service, or an internal boundary failure. Neither signal alone proves an attack or an application defect, but both need a runbook.

Deployment checklist

  • Keep each protected upstream reachable only through the auth proxy, or enforce equivalent checks at the upstream.
  • Store JWT_SECRET and any local bypass value in the deployment secret manager, outside version control.
  • Verify the Auth.js cookie name, JWT_SALT, issuer, audience, lifetime, and browser cookie flags together.
  • Confirm that /verify-jwe is internal and cannot be called from the public network.
  • Confirm caller-supplied identity and bypass headers are stripped before trusted headers are set.
  • Keep ALLOW_LOCAL_BYPASS=false outside direct loopback development and test all route exceptions as exact paths.
  • Keep ENABLE_DB_CHECK=false until a database verifier, cache freshness policy, and failure behavior are implemented and tested.
  • Alert on verification-service failures and review logs for token or secret leakage before shipping.

Bring the question, the work, and the answer into one governed workspace.