Kanidm SSO for Frigate

Because the Frigate Nix module brings an extensive Nginx config, I found it difficult to integrate Frigate with KanIDM for SSO. I was able to make a fixed user+role work but wasn’t able to map KanIDM users and roles to Frigate users and roles.

Was anyone able to make such a setup work? Is such a config publicly available?

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

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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:

  1. 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.
  2. 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.
  3. 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";
 };
  1. This would generate the auth_request_set + proxy_set_header block automatically for all protected locations.