Identity-aware access to internal tools without a VPN
For software teams that want to expose internal web tools safely without putting everyone on a full-tunnel VPN. This guide explains where identity-aware proxies sit in the request path, how the auth flow works end to end, and what to configure in nginx, OAuth/OIDC, and headers so developers can make sound trade-offs.
TL;DR — If your team mainly needs browser access to internal HTTP tools like Grafana, admin panels, build dashboards, and staging apps, put an identity-aware reverse proxy in front of them instead of forcing everyone onto a VPN. The usual winning pattern is: public DNS + TLS at a proxy, OIDC login against your IdP, short-lived session cookie or token, and strict header forwarding to the upstream app. Reading time: ~7 min
What it is and where it sits
For a software team, “zero trust without a VPN” usually means this: internal tools are reachable over normal HTTPS, but every request is gated by identity, device/session policy, and explicit authorization at an application proxy layer rather than by “you are on the corporate network”.
In practice, the component is an identity-aware proxy or access gateway in front of internal web apps. It replaces the old pattern of:
- connect laptop to VPN
- get an IP on the private network
- rely on network reachability as the main control
with this pattern:
- hit
https://tool.example.com - get redirected to your IdP via OIDC/SAML
- return with a signed identity assertion/session
- proxy injects trusted identity headers to the upstream
- upstream app never sees the public internet directly
Where it lives in a typical flow:
- DNS points the tool hostname at the proxy or load balancer.
- TLS terminates at the proxy.
- The proxy talks to your IdP for login.
- The proxy forwards to the internal service over private network, localhost, or a service mesh.
- The app trusts only the proxy, not arbitrary client headers.
View as text
[Developer Browser] ---- 1. GET https://grafana.example.com ----> [Access Proxy] [Access Proxy] ---- 2. redirect to IdP -------------------------> [IdP] [IdP] ---- 3. auth code / assertion ---------------------------> [Access Proxy] [Access Proxy] ---- 4. proxied GET with identity headers ------> [Internal Tool] [Internal Tool] ---- 5. response ------------------------------> [Access Proxy] [Access Proxy] ---- 6. HTML/JSON ------------------------------> [Developer Browser]
What talks to it:
- Browsers: easiest case; cookies and redirects work naturally.
- CLI clients: possible, but only if you support bearer tokens, mTLS, or a machine-oriented flow. Browser-only login is awkward for
curl, CI jobs, and webhooks. - Upstream apps: should accept auth context from the proxy via headers like
X-Forwarded-User,X-Forwarded-Email, or a verified JWT, depending on what the app supports.
What it does not replace well:
- arbitrary TCP/UDP access
- SSH to many hosts unless you add a separate identity-aware SSH layer
- legacy protocols that assume flat network trust
How it actually works
Walk one realistic example: a developer opens an internal Grafana instance at https://grafana.example.com, with nginx acting as the reverse proxy and an OIDC-capable auth service in front of Grafana.
Step 1: initial request hits the proxy
The browser requests the tool:
curl -I https://grafana.example.com
If the user has no session yet, a healthy setup usually returns a redirect, not a 200:
HTTP/2 302
server: nginx
location: https://login.example.com/authorize?client_id=grafana-proxy&redirect_uri=https%3A%2F%2Fgrafana.example.com%2Foauth2%2Fcallback&response_type=code&scope=openid%20email%20profile&state=8f3...
set-cookie: _oauth2_proxy_csrf=8d1...; Path=/; Secure; HttpOnly; SameSite=Lax
If you get this instead:
HTTP/2 200
content-type: text/html
then your proxy is probably passing traffic straight through without enforcing auth.
If you get this instead:
HTTP/2 500
common causes are a bad client secret, unreachable IdP discovery URL, or a redirect URI mismatch.
Step 2: user authenticates with the IdP
The proxy sends the user to your IdP’s authorization endpoint. The IdP authenticates the user, then redirects back to the proxy callback with an authorization code.
The proxy exchanges that code for tokens:
- ID token: who the user is
- access token: optional, for APIs
- refresh token: optional, if you want longer sessions without forcing re-login
The proxy validates signature, issuer, audience, expiry, and often group claims.
Step 3: proxy creates a session and forwards identity
After token validation, the proxy stores session state in an encrypted cookie or server-side session store. Then it forwards the original request to Grafana with trusted headers.
Typical forwarded headers:
X-Forwarded-User: alice
X-Forwarded-Email: alice@example.com
X-Forwarded-Groups: sre,observability
X-Forwarded-Proto: https
X-Forwarded-Host: grafana.example.com
Critical rule: the upstream must only accept these headers from the proxy. If the app is directly reachable, a client can spoof X-Forwarded-User: admin.
Step 4: upstream app authorizes based on identity
Grafana or your app maps the forwarded identity to roles. If the app cannot consume headers safely, put auth entirely at the proxy and keep the app inaccessible except from the proxy’s private IPs or loopback.
Step 5: later requests use the session cookie
Subsequent browser requests carry the session cookie, so the proxy does not redirect to the IdP every time.
When this breaks, the symptoms are usually obvious:
- infinite redirect loop: cookie domain/path mismatch, bad
X-Forwarded-Proto, or callback URL mismatch - login succeeds but app shows anonymous user: headers not forwarded, app not configured to trust them, or auth_request subrequest not wiring variables correctly
- intermittent 401 after some minutes: clock skew, too-short cookie expiry, refresh disabled, or load-balanced proxies without shared session state
A realistic redirect-loop diagnostic:
curl -k -I https://grafana.example.com
HTTP/2 302
location: https://login.example.com/authorize?client_id=grafana-proxy...
set-cookie: _oauth2_proxy_csrf=abc...; Path=/; Secure; HttpOnly; SameSite=Lax
Then after browser login, developer lands back and immediately gets redirected again. Check whether nginx tells the auth layer the original scheme was HTTPS. If not, the auth layer may mint cookies for http or generate the wrong callback URL.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| Internal web tools used by humans in a browser | Strong fit. Put an identity-aware proxy in front of each hostname or a shared gateway. |
| You want SSO, MFA, short sessions, and per-app access without full network access | Strong fit. This is the main reason to do it. |
| CI jobs, webhooks, and service-to-service traffic need access | Use machine auth separately: mTLS, workload identity, signed JWTs, or API gateways. Do not force browser-style login onto automation. |
| You need SSH, RDP, databases, or arbitrary TCP for many targets | Identity-aware web access alone is not enough. Use a separate zero-trust access layer for those protocols or keep a VPN for that subset. |
| Tiny team, one private admin app, static office IPs, low compliance pressure | You probably don’t need this yet. An IP allowlist plus SSO at the app may be enough. |
| Legacy app cannot handle reverse proxy headers/callbacks and breaks under subpaths or redirects | Budget time for adaptation, or don’t front it this way yet. |
You probably don’t need this if all of these are true:
- only two or three admins need access
- the app already has solid SSO and MFA
- access comes from a fixed network you control
- there is no need to expose it on the public internet
Trade-offs
Benefit and cost come together here; don’t sell this internally as free security.
- Better access control than VPN-by-IP
- Cost: more moving parts — IdP app registration, callback URLs, cookies, header trust, proxy config.
- Per-app exposure instead of full network exposure
- Cost: every app needs its own hostname, TLS, and often custom auth/header handling.
- Good user experience for browser tools
- Cost: CLI and automation become a second design problem. You need token or machine identity flows.
- Centralized revocation and policy
- Cost: your IdP and proxy become critical path. Outage there means nobody reaches internal tools.
- Smaller lateral movement blast radius
- Cost: more DNS, certificate, and proxy operations. Teams must understand trust boundaries.
- No full-tunnel VPN latency for unrelated traffic
- Cost: each request may add a proxy hop and auth subrequest. Usually small, but noticeable for chatty apps.
- Auditable identity at the edge
- Cost: privacy and log hygiene work. You will log user identifiers in access logs unless you deliberately minimize them.
Lock-in risk is real. The less your upstream apps depend on vendor-specific headers and policy syntax, the easier migration is. Prefer standard OIDC claims and generic reverse-proxy patterns over proprietary app connectors when possible.
In practice
Example 1: nginx in front of an auth service and an internal app
server {
listen 443 ssl http2;
server_name grafana.example.com;
ssl_certificate /etc/letsencrypt/live/grafana.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.example.com/privkey.pem;
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Auth-Request-Redirect $request_uri;
}
location = /oauth2/auth {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
location / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-Forwarded-User $user;
proxy_set_header X-Forwarded-Email $email;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_pass http://10.0.12.34:3000;
}
}
This pattern uses nginx auth_request to ask a local auth service whether the request is authenticated, then forwards identity headers to the app. Gotcha: if X-Forwarded-Proto is wrong or omitted, expect callback URL bugs and redirect loops.
Example 2: OIDC-capable auth proxy configuration
provider = "oidc"
oidc_issuer_url = "https://login.example.com/realms/engineering"
client_id = "grafana-proxy"
client_secret = "REDACTED"
redirect_url = "https://grafana.example.com/oauth2/callback"
email_domains = [ "example.com" ]
upstreams = [ "http://10.0.12.34:3000/" ]
http_address = "127.0.0.1:4180"
reverse_proxy = true
set_xauthrequest = true
pass_access_token = false
pass_authorization_header = false
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax"
whitelist_domains = [ ".example.com" ]
This tells the auth proxy to use OIDC, listen only on localhost, and emit X-Auth-Request-* headers for nginx to consume. Gotcha: redirect_url must exactly match the callback registered in the IdP, including scheme, host, and path.
Example 3: quick diagnostics you can run now
# Check the redirect target and callback shape
curl -I https://grafana.example.com
# Follow redirects and inspect where the loop happens
curl -k -I -L https://grafana.example.com
# Verify the upstream app is not directly exposed
curl -I http://10.0.12.34:3000
# Inspect nginx config for auth_request wiring
nginx -T | sed -n '/server_name grafana.example.com/,/}/p'
If direct upstream access returns a public 200 from outside your private network, your trust boundary is wrong. The app should be reachable only from the proxy path, private network, or localhost.
⚠️ Before changing callback URLs, cookie settings, or trusted proxy headers in production, expect temporary login failures for every user of that app. Roll changes during a low-traffic window and keep an existing admin session open in a separate browser profile so you do not lock yourself out.
Further reading
- OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0
- nginx
auth_requestmodule documentation - The "HTTP authentication" and "Redirections" sections of the MDN HTTP docs
- NIST SP 800-207 Zero Trust Architecture
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI