Running behind a TLS-terminating reverse proxy
This guide walks through deploying NanoIDP behind a reverse proxy (nginx,
Traefik, an API gateway, etc.) that terminates TLS and forwards plain HTTP
to NanoIDP - a common setup in containerized environments (Docker Compose,
Kubernetes). It composes six existing, independently-documented settings
into one end-to-end configuration, since getting this wrong can silently
produce a working-looking IdP that issues tokens with the wrong issuer.
None of this is required for local development - http://localhost:8000
with the defaults works out of the box. Use this guide once NanoIDP needs to
be reachable through a proxy under a public hostname.
The problem this solves
A discovery document's issuer, a token's iss claim, and the device flow's
verification_uri must all agree with whatever hostname the client actually
used to reach NanoIDP. Behind a reverse proxy, NanoIDP itself only ever sees
the proxy's own internal connection (e.g. http://nanoidp:8000), never the
public hostname the end user typed (e.g. https://idp.example.com) - unless
it's told how to recover that information from the request.
Getting this wrong shows up as: a discovery issuer that doesn't match the
URL it was fetched from, a token rejected because its iss doesn't match the
resource server's expected issuer, or a device-flow verification_uri that
points at a hostname the end user's browser can't resolve.
Option A: fixed public issuer (simplest)
If NanoIDP is only ever reachable under one public hostname, skip request-based
derivation entirely and set a fixed issuer, resolved from the environment so
the same settings.yaml works in every environment:
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000}
Set OAUTH_ISSUER=https://idp.example.com in the proxy's environment (or the
container's), and every environment that doesn't set it falls back to the
http://localhost:8000 default for local development. This is the
recommended starting point - it requires none of the request-derivation flags
below, and there's nothing for a spoofed header to influence.
Option B: multiple hostnames (request-derived issuer)
If NanoIDP must be reachable under more than one hostname at once (e.g. a
Docker Compose service name from other containers and localhost from the
host browser, or several public hostnames behind the same proxy), a single
fixed issuer can't satisfy all of them. Instead, derive it per request:
oauth:
issuer_from_request: true
With this on, discovery's issuer, a token's iss, and the device flow's
verification_uri all reflect the Host header of the request that fetched or
requested them - each hostname NanoIDP is reached under consistently
advertises and issues tokens against itself.
This introduces two trust problems, both of which have a corresponding flag:
1. The proxy terminates TLS
By default, NanoIDP builds the issuer from its own request.scheme and
host_url, which - behind a reverse proxy - reflect the proxy's own
internal, unencrypted connection to NanoIDP (http://), not the scheme and
host the end user's browser used (https://idp.example.com). Tell NanoIDP to
trust the proxy's forwarding headers instead:
oauth:
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_from_proxy_headers applies werkzeug's ProxyFix for a single
trusted proxy hop, so request.scheme / host_url follow
X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-For instead of the
proxy's own connection.
Security caveat: only enable this when NanoIDP sits directly behind exactly one trusted reverse proxy that sets or overwrites these headers itself. If a client can reach NanoIDP directly (bypassing the proxy) or the proxy blindly forwards client-supplied
X-Forwarded-*headers, this setting lets an untrusted client spoof its own scheme, host and IP - and the resulting effect isn't limited to the issuer:X-Forwarded-Foralso feeds rate-limit and audit-log client IP attribution, regardless of whetherissuer_from_requestis even on. Configure the proxy to strip any inboundX-Forwarded-*headers from clients before it sets its own.This is wired at app startup, so a value changed at runtime (Settings page or MCP
update_settings) only takes effect after a restart.
2. Trusting an arbitrary Host header
Even with the proxy hop trusted, issuer_from_request still reflects
whatever Host it's given. On a network where anything other than the proxy
could reach NanoIDP, restrict which hostnames are allowed to be reflected:
oauth:
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
Each entry is an origin: scheme://host[:port]. A request whose derived
origin isn't in the list falls back to the fixed oauth.issuer instead of
trusting the Host header. Leaving issuer_allowlist empty (the default)
allows any Host, matching pre-#issuer_allowlist behavior - only appropriate
on a fully trusted network where the proxy is the sole entry point.
Device flow: pinning a human-reachable URL
The device flow's verification_uri is opened by a human in their own
browser, but /device_authorization is typically called by a
backend/container/CLI on a different host than that browser - so reflecting
that caller's Host (e.g. Host: nanoidp:8000, an internal Docker Compose
service name) produces a URL the human can't actually reach.
Pin it to a fixed, human-reachable URL instead, independent of whatever
Host called /device_authorization:
oauth:
issuer_from_request: true
device_verification_base_url: "https://idp.example.com"
device_verification_base_url only overrides the device flow's
verification_uri; discovery's issuer and a token's iss are unaffected
and keep following the request that fetched/requested them. It's only
consulted when issuer_from_request is on.
Reloading configuration without a restart
ProxyFix (via issuer_from_proxy_headers) is wired once at app startup, so
a value changed at runtime only takes effect after a process restart.
Everything else in this guide - issuer, issuer_allowlist,
device_verification_base_url - can be changed in settings.yaml (or via the
Settings page / MCP update_settings) and picked up without a restart:
curl -X POST http://localhost:8000/api/config/reload
This re-reads both settings.yaml and users.yaml from
NANOIDP_CONFIG_DIR (default ./config) in place.
Putting it together
A full settings.yaml for NanoIDP behind a single trusted TLS-terminating
proxy, reachable under both its public hostname and an internal Docker
Compose service name, with the device flow pinned to the public hostname:
server:
host: "0.0.0.0"
port: 8000
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000} # fallback when the flags below don't match
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
device_verification_base_url: "https://idp.example.com"
Note on
host: "0.0.0.0". Since 2.6.0 the default bind address is127.0.0.1(GHSA-2473-px8h-rvg6): the/api/*management API is unauthenticated by design, so NanoIDP no longer listens on all interfaces unless you ask it to. Behind a reverse proxy you do want0.0.0.0- the proxy must reach NanoIDP over the container or host network - so setting it here is a deliberate opt-in. Only do this on a trusted network (for example a private Docker Compose network that publishes just the proxy's port), and expect a startup warning reminding you the management API is exposed to that network.
And the corresponding environment for the container:
environment:
- NANOIDP_CONFIG_DIR=/app/config
- OAUTH_ISSUER=https://idp.example.com
Checklist
-
Reverse proxy terminates TLS and sets
X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Foritself (strips any client-supplied values first) - NanoIDP is not reachable except through that proxy
-
issuer_from_proxy_headers: trueonly if the above two hold -
issuer_allowlistset to the exact origins NanoIDP should ever advertise as its issuer -
device_verification_base_urlset if/device_authorizationis ever called from a different host than the end user's own browser -
Restarted the process after changing
issuer_from_proxy_headers(ProxyFixis startup-only)
Related documentation
- Configuration reference - full
settings.yamlshape - Environment variables -
NANOIDP_CONFIG_DIRand MCP-related variables - Security guide: multi-hostname issuer -
the trust model behind
issuer_from_request - Endpoints reference -
GET /api/config,POST /api/config/reload
SAML metadata follows the issuer
saml.entity_id and saml.sso_url are optional. When they are absent from
settings.yaml, nanoidp derives them from the effective issuer as
<issuer>/saml and <issuer>/saml/sso, so /saml/metadata, the <Issuer>
in responses and assertions, and OIDC discovery all name the same origin under
every option above (fixed issuer, request-derived issuer, proxy headers,
allowlist). Nothing SAML-specific needs to be configured for a proxy.
Set them explicitly only when the SP expects a fixed, different value; an
explicit value always wins and does not follow the request. GET /api/config
reports the effective values together with entity_id_derived /
sso_url_derived so you can tell which case applies.