claude-code came up with this (after many attempts)
Perhaps it’s useful to anyone:
Got this working on NixOS with the KanIDM NixOS module + oauth2-proxy. Posting the full solution since there were several non-obvious
gotchas.
Setup: Frigate 0.17.1 running in a NixOS container, served by the services.frigate NixOS module, fronted by nginx, auth via KanIDM OIDC
→ oauth2-proxy. End result: automatic login, dashboard shows “Current User: zoechi (Admin)”.
Architecture
Browser → nginx (TLS)
→ server-level auth_request /oauth2/auth (guards static root /)
→ /auth location → oauth2-proxy /oauth2/auth (validates KanIDM session)
→ every API location: auth_request /auth + captures X-Auth-Request-Preferred-Username
→ sets Remote-User header for Frigate
→ Frigate reads Remote-User / Remote-Role via proxy.header_map
Frigate settings
services.frigate.settings = {
# Must be false. With auth.enabled = true, Frigate's /auth endpoint requires
# a JWT cookie; no native users exist so it always 401s → login screen.
auth.enabled = false;
proxy = {
header_map = {
user = "Remote-User";
role = "Remote-Role";
};
default_role = "admin"; # group membership via KanIDM controls who can authenticate
};
};
KanIDM provisioning
services.kanidm.provision.systems.oauth2."frigate_oauth2" = {
displayName = "Frigate";
originUrl = "https://frigate.example.com/oauth2/callback";
originLanding = "https://frigate.example.com";
basicSecretFile = "/run/vault-agent/kanidm/frigate_oauth2_secret";
preferShortUsername = true; # gives preferred_username = "zoechi" not a UUID
scopeMaps."frigate_users" = [ "openid" "profile" "email" ];
};
preferShortUsername = true is important — without it preferred_username in the OIDC token is a UUID, not the human-readable name.
oauth2-proxy
services.oauth2-proxy = {
enable = true;
provider = "oidc";
clientID = "frigate_oauth2";
oidcIssuerUrl = "https://idm.example.com/oauth2/openid/frigate_oauth2";
redirectURL = "https://frigate.example.com/oauth2/callback";
httpAddress = "127.0.0.1:4180";
keyFile = "/run/secrets/frigate_oauth2_proxy.env"; # OAUTH2_PROXY_CLIENT_SECRET + OAUTH2_PROXY_COOKIE_SECRET
setXauthrequest = true; # makes /oauth2/auth return X-Auth-Request-Preferred-Username etc.
extraConfig = {
upstream = "static://200";
skip-provider-button = true;
email-domain = "*";
cookie-secure = true;
cookie-samesite = "lax";
code-challenge-method = "S256"; # required for KanIDM
};
};
For the cookie secret: use openssl rand -hex 16, not openssl rand -base64 32. base64 output is 44 bytes which exceeds oauth2-proxy’s
limit and causes a startup error.
nginx virtualHost
This is where most of the complexity lives. The services.frigate NixOS module auto-generates nginx config, so we need to work with (and
around) it.
Put this in a let binding — appended to every top-level API location
authHeaderOverride = ''
auth_request_set $x_user $upstream_http_x_auth_request_preferred_username;
auth_request_set $x_email $upstream_http_x_auth_request_email;
proxy_set_header Remote-User $x_user;
proxy_set_header Remote-Role "admin";
proxy_set_header Remote-Email $x_email;
'';
# Also put this in a let binding — used for /api/stats and /api/version
statsVersionLocation = {
proxyPass = "http://frigate-api";
recommendedProxySettings = true;
extraConfig = ''
auth_request /auth;
auth_request_set $x_user $upstream_http_x_auth_request_preferred_username;
auth_request_set $x_email $upstream_http_x_auth_request_email;
proxy_set_header Remote-User $x_user;
proxy_set_header Remote-Role "admin";
proxy_set_header Remote-Email $x_email;
access_log off;
rewrite ^/api(/.*)$ $1 break;
add_header Cache-Control "no-store";
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_redirect http:// $scheme://;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;
'';
};
nginx.virtualHosts."frigate.example.com" = {
forceSSL = true;
# ...
# Covers the root "/" static-file location (not covered by the module's per-location auth)
extraConfig = ''
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
'';
# Redirect the module's /auth location to oauth2-proxy instead of Frigate port 5001.
# lib.mkForce is needed because proxyPass is types.nullOr str — highest priority wins.
# extraConfig uses lib.mkForce too because it's types.lines (would otherwise concatenate).
locations."/auth" = {
proxyPass = lib.mkForce "http://127.0.0.1:4180/oauth2/auth";
recommendedProxySettings = true;
extraConfig = lib.mkForce ''
internal;
proxy_pass_request_headers off;
proxy_pass_request_body off; # required — without this, POST requests timeout
proxy_set_header Content-Length ""; # required alongside the above
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Auth-Request-Redirect $request_uri;
'';
};
# oauth2-proxy UI (sign-in page, callback, sign-out)
locations."/oauth2/" = {
proxyPass = "http://127.0.0.1:4180";
extraConfig = ''
auth_request off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Auth-Request-Redirect $request_uri;
'';
};
# Append authHeaderOverride to every top-level location the module creates.
# lib.mkAfter (priority 1500) ensures our proxy_set_header Remote-User comes
# AFTER the module's (priority 1000), so it wins.
locations."/api/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/vod/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/stream/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/clips/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/recordings/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/exports/".extraConfig = lib.mkAfter authHeaderOverride;
locations."/ws".extraConfig = lib.mkAfter authHeaderOverride;
locations."/live/jsmpeg".extraConfig = lib.mkAfter authHeaderOverride;
locations."/live/mse/api/ws".extraConfig = lib.mkAfter authHeaderOverride;
locations."/live/webrtc/api/ws".extraConfig = lib.mkAfter authHeaderOverride;
locations."/live/webrtc/webrtc.html".extraConfig = lib.mkAfter authHeaderOverride;
locations."/api/go2rtc/api".extraConfig = lib.mkAfter authHeaderOverride;
locations."/api/go2rtc/webrtc".extraConfig = lib.mkAfter authHeaderOverride;
locations."~* /api/.*\\.(jpg|jpeg|png|webp|gif)$".extraConfig = lib.mkAfter authHeaderOverride;
# The module embeds /api/stats, /api/version, /api/vod/ as string literals
# inside /api/'s extraConfig (types.lines). lib.mkAfter cannot reach them.
# Solution: define top-level NixOS locations. nginx longest-prefix matching
# picks location /api/stats (9-char prefix) over location /api/ (5-char prefix),
# shadowing the inline nested versions.
locations."/api/stats" = statsVersionLocation;
locations."/api/version" = statsVersionLocation;
locations."/api/vod/" = {
proxyPass = "http://frigate-api/vod/";
recommendedProxySettings = true;
extraConfig = ''
auth_request /auth;
auth_request_set $x_user $upstream_http_x_auth_request_preferred_username;
auth_request_set $x_email $upstream_http_x_auth_request_email;
proxy_set_header Remote-User $x_user;
proxy_set_header Remote-Role "admin";
proxy_set_header Remote-Email $x_email;
proxy_cache off;
add_header Cache-Control "no-store";
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_redirect http:// $scheme://;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;
'';
};
};
Gotchas
- Don’t use set $var as an intermediate variable for auth headers
BROKEN — $x_email is always empty
set $display_user $x_email;
proxy_set_header Remote-User $display_user;
CORRECT — evaluate $x_user directly
proxy_set_header Remote-User $x_user;
set executes in nginx’s rewrite phase (phase 3), before auth_request_set populates variables in the access phase (phase 6).
proxy_set_header evaluates variables lazily in the content phase (phase 9), so direct variable references work correctly. Intermediate
set variables capture empty strings.
- POST requests need proxy_pass_request_body off
Without proxy_pass_request_body off and proxy_set_header Content-Length “” in the /auth location, POST requests (mark as viewed,
exports, etc.) cause oauth2-proxy to hang waiting for a request body that never arrives. The symptom is nginx logging upstream timed
out (110: Connection timed out) on the auth subrequest. proxy_pass_request_headers off alone is not sufficient.
- The module’s extraConfig is types.lines — it concatenates, not replaces
lib.mkForce on extraConfig prepends (priority 50 < module default 100). lib.mkAfter appends (priority 1500 > 1000). There is no way
to replace the module’s extraConfig — only prepend or append. proxyPass is a scalar (types.nullOr str) so lib.mkForce there does
replace correctly.
- Port 5000 does not exist in the module
The module serves the web UI from the Nix store directly (static files). The upstream ports are:
- frigate-api → 127.0.0.1:5001 (API)
- frigate-mqtt-ws → 127.0.0.1:5002
- frigate-go2rtc → 127.0.0.1:1984
Do not override locations.“/” with a proxyPass — it returns the API health check, not the web UI.
- The Frigate SWR global error handler fires on any 401
The Frigate frontend has a global SWR onError handler that navigates to /login on any 401, 302, or 307 from any API call. This means
even /api/stats or /api/version returning 401 (due to missing Remote-User header) will kick you to the login screen, even if
/api/profile is returning 200. This is why the top-level location overrides for /api/stats, /api/version, and /api/vod/ are necessary.
What would make this easier in the NixOS module
A couple of module-level improvements would eliminate most of the above complexity:
- Make the auth endpoint configurable. The module hardcodes auth_request /auth pointing to Frigate port 5001. An option like
services.frigate.nginx.authRequestUrl = “http://127.0.0.1:4180/oauth2/auth” (or simply allowing the /auth location to be configured
without needing lib.mkForce) would let users plug in any auth backend cleanly.
- Make /api/stats, /api/version, /api/vod/ proper NixOS location objects. Currently they’re embedded as string literals inside /api/'s
extraConfig (a types.lines value). If they were defined as locations.“/api/stats” = { … } in the module, they’d be patchable with
lib.mkAfter like everything else, removing the need for the top-level override workaround.
- Expose a reverseProxyHeaders option that handles the auth_request_set / proxy_set_header Remote-User pattern for the case where the
upstream auth server returns user info in response headers (the standard oauth2-proxy pattern). Something like:
services.frigate.nginx.reverseProxy = {
userHeader = "X-Auth-Request-Preferred-Username";
roleHeader = "X-Auth-Request-Role";
defaultRole = "admin";
};
- This would generate the auth_request_set + proxy_set_header block automatically for all protected locations.