Put OAuth2 Proxy in Front of an Internal Web App with Nginx
For developers protecting an internal HTTP app with identity-aware access instead of a VPN. This walks you through standing up OAuth2 Proxy behind Nginx, wiring it to an OpenID Connect provider, and verifying that unauthenticated requests are redirected to login while authenticated users reach the app.
TL;DR — Put
oauth2-proxyin front of your internal app, terminate TLS at Nginx, and useauth_requestso every request is checked before it reaches the upstream. The most common failure is a redirect/cookie mismatch: set the OAuth callback URL, cookie domain, and reverse-proxy headers to the exact external hostname. Reading time: ~5 min
Goal
When you are done, requests to https://app.internal.example.com will redirect unauthenticated users to your OpenID Connect identity provider, authenticated users will reach the internal web app, and the app itself will no longer be directly reachable except from the local host or private network.
Prerequisites
- A Linux host or VM running the app and reachable on ports
80and443 - Root or
sudoaccess on that host - Your app already listening on HTTP, example:
127.0.0.1:3000 - A DNS record for the public hostname, example:
app.internal.example.com, pointing to the host - An OpenID Connect provider with authority to create a confidential client application
- The following OIDC values at hand:
- issuer URL, example:
https://login.example-idp.com/realms/engineering - client ID
- client secret
- issuer URL, example:
nginx >= 1.22— check with:
nginx -v
curl >= 8— check with:
curl --version | head -n1
openssl— check with:
openssl version
- A TLS certificate for the public hostname, or ACME tooling already in use on the host
Steps
Step 1: Lock the app to localhost
If your app is listening on 0.0.0.0, move it to 127.0.0.1 so only the proxy can reach it.
Example for a systemd-managed app using environment variables:
sudo systemctl edit myapp
Add exactly:
[Service]
Environment="HOST=127.0.0.1"
Environment="PORT=3000"
Then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart myapp
ss -ltnp | grep ':3000'
You should see 127.0.0.1:3000 in ss output, not 0.0.0.0:3000.
Step 2: Create the OIDC client in your identity provider
In your provider's dashboard, create a confidential OIDC client with these exact values:
- Redirect/callback URL:
https://app.internal.example.com/oauth2/callback - Allowed web origins:
https://app.internal.example.com - Response type:
code - Grant type:
authorization_code - Client authentication:
client_secret_basicorclient_secret_post
You should get a client_id and client_secret.
Step 3: Install oauth2-proxy
On Debian/Ubuntu, download the current Linux AMD64 release from the official project and install it:
cd /tmp
curl -LO https://github.com/oauth2-proxy/oauth2-proxy/releases/latest/download/oauth2-proxy-v7.7.1.linux-amd64.tar.gz
tar -xzf oauth2-proxy-v7.7.1.linux-amd64.tar.gz
sudo install oauth2-proxy-v7.7.1.linux-amd64/oauth2-proxy /usr/local/bin/oauth2-proxy
/usr/local/bin/oauth2-proxy --version
You should see a version line ending in v7.7.1.
Step 4: Generate the cookie secret and write the oauth2-proxy config
Generate a 32-byte secret and write the config file.
sudo mkdir -p /etc/oauth2-proxy
COOKIE_SECRET=$(openssl rand -base64 32)
echo "$COOKIE_SECRET"
Copy the printed value, then create /etc/oauth2-proxy/oauth2-proxy.cfg:
sudo tee /etc/oauth2-proxy/oauth2-proxy.cfg >/dev/null <<'EOF'
provider = "oidc"
oidc_issuer_url = "https://login.example-idp.com/realms/engineering"
client_id = "REPLACE_CLIENT_ID"
client_secret = "REPLACE_CLIENT_SECRET"
redirect_url = "https://app.internal.example.com/oauth2/callback"
http_address = "127.0.0.1:4180"
reverse_proxy = true
email_domains = [ "*" ]
upstreams = [ "http://127.0.0.1:3000/" ]
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax"
cookie_domains = [ ".internal.example.com" ]
cookie_secret = "REPLACE_COOKIE_SECRET"
set_xauthrequest = true
pass_access_token = true
pass_authorization_header = true
pass_user_headers = true
scope = "openid profile email"
EOF
sudo chmod 600 /etc/oauth2-proxy/oauth2-proxy.cfg
Replace REPLACE_CLIENT_ID, REPLACE_CLIENT_SECRET, and REPLACE_COOKIE_SECRET with the literal values.
You should have a readable-by-root config at /etc/oauth2-proxy/oauth2-proxy.cfg.
Step 5: Run oauth2-proxy as a service
Create the systemd unit:
sudo tee /etc/systemd/system/oauth2-proxy.service >/dev/null <<'EOF'
[Unit]
Description=OAuth2 Proxy
After=network.target
[Service]
ExecStart=/usr/local/bin/oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
Restart=on-failure
RestartSec=3
User=nobody
Group=nogroup
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
sudo systemctl status --no-pager oauth2-proxy
You should see Active: active (running).
Step 6: Configure Nginx to enforce authentication
⚠️ This changes the live reverse-proxy path for the app. If you overwrite the wrong server block, you can cause downtime for the hostname until Nginx is reloaded with a valid config.
Create the Nginx site config:
sudo tee /etc/nginx/sites-available/app.internal.example.com.conf >/dev/null <<'EOF'
server {
listen 80;
server_name app.internal.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.internal.example.com;
ssl_certificate /etc/letsencrypt/live/app.internal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.internal.example.com/privkey.pem;
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-Uri $request_uri;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
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-Forwarded-Host $host;
}
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-User $user;
proxy_set_header X-Email $email;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_pass http://127.0.0.1:3000;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/app.internal.example.com.conf /etc/nginx/sites-enabled/app.internal.example.com.conf
sudo nginx -t
sudo systemctl reload nginx
You should see syntax is ok and test is successful before reload completes.
Step 7: Confirm the request flow before testing in a browser
Use curl to verify redirect behavior.
curl -I http://app.internal.example.com
curl -k -I https://app.internal.example.com
Expected shape:
HTTP/1.1 301 Moved Permanently
Location: https://app.internal.example.com/
Then:
HTTP/2 302
location: /oauth2/sign_in
set-cookie: _oauth2_proxy=...; Path=/; Domain=.internal.example.com; HttpOnly; Secure; SameSite=Lax
You should get 301 on HTTP and 302 to /oauth2/sign_in on HTTPS before login.
Verify it works
Run the end-to-end checks:
curl -k -I https://app.internal.example.com/oauth2/sign_in
journalctl -u oauth2-proxy -n 50 --no-pager
Expected results:
curlreturnsHTTP/2 200orHTTP/2 302with aLocation:header pointing at your identity provider's authorization endpoint.journalctlshows requests without repeated errors such asredirect_uri_mismatchorinvalid_client.
Then test in a browser:
- Open
https://app.internal.example.comin a private window. - You should be redirected to your identity provider.
- After login, you should land back on
https://app.internal.example.com/and the app should load.
Request flow:
View as text
[Browser] ---- 1. GET / ----> [Nginx] [Nginx] ---- 2. subrequest /oauth2/auth ----> [oauth2-proxy] [oauth2-proxy] ---- 3. 401 ----> [Nginx] [Nginx] ---- 4. 302 /oauth2/sign_in ----> [Browser] [Browser] ---- 5. auth request ----> [IdP] [IdP] ---- 6. callback code ----> [oauth2-proxy] [oauth2-proxy] ---- 7. session cookie ----> [Browser] [Browser] ---- 8. GET / with cookie ----> [Nginx] [Nginx] ---- 9. proxy_pass ----> [App]
Common pitfalls
Callback URL does not exactly match
Mistake: the IdP client has https://app.internal.example.com/oauth2/callback/ with a trailing slash, but oauth2-proxy uses /oauth2/callback.
Symptom: login succeeds at the IdP, then you get an IdP error like redirect_uri_mismatch or a 500 in oauth2-proxy logs.
Fix: set both sides to the exact same callback URL: https://app.internal.example.com/oauth2/callback.
Cookie domain does not match the public hostname
Mistake: cookie_domains is set to .example.com or app.example.com while the app is served from app.internal.example.com.
Symptom: successful login loops back to /oauth2/sign_in repeatedly; browser devtools show the cookie not being stored or not sent.
Fix: set cookie_domains = [ ".internal.example.com" ] or the exact host domain if you only need one host.
Missing reverse-proxy headers
Mistake: Nginx does not send X-Forwarded-Proto https and X-Forwarded-Host $host to oauth2-proxy.
Symptom: oauth2-proxy generates redirects to http://127.0.0.1:4180/... or http://app.internal.example.com/... instead of HTTPS.
Fix: add these exact lines in both /oauth2/auth and /oauth2/ locations:
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
App is still exposed directly
Mistake: the app still listens on 0.0.0.0:3000 and the host firewall allows inbound traffic to 3000.
Symptom: users can bypass the identity-aware proxy by visiting http://host-ip:3000.
Fix: bind the app to 127.0.0.1:3000 and block external access to 3000 in the host firewall or security group.
Nginx auth subrequest forwards the request body
Mistake: /oauth2/auth does not include proxy_pass_request_body off; and proxy_set_header Content-Length "";.
Symptom: POST requests fail intermittently or auth checks are slow; Nginx error log may show upstream request issues.
Fix: add these exact lines under location = /oauth2/auth and reload Nginx.
TLS cert path is wrong or not provisioned yet
Mistake: the Nginx server block points to /etc/letsencrypt/live/app.internal.example.com/... before the certificate exists.
Symptom: nginx -t fails with output shaped like:
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/app.internal.example.com/fullchain.pem": BIO_new_file() failed
nginx: configuration file /etc/nginx/nginx.conf test failed
Fix: issue the certificate first with your ACME client, or temporarily serve only port 80 until the cert files exist.
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