Skip to main content

Put Every Admin Panel Behind SSO and an IP Allowlist — Traefik + authentik in one afternoon

·15 mins· loading · loading · ·
Homelab Security - This article is part of a series.
Part 1: This Article

Every homelab ends up with a half-dozen admin panels — a hypervisor, a NAS, a container UI, a dashboard, a switch, a DNS box. They all sit behind a reverse proxy already, and most of them are published to the internet with nothing in front but a username and a password per panel. This post wires one front door for all of them: SSO through authentik using Traefik’s forwardAuth, plus an IP allowlist bound to a single trusted segment. Both arrive as two reusable middleware snippets, so adding the next admin UI costs one line of router config.

Step 1: The problem this fixes
#

“Traefik and nothing in front” is the default because it works. Certificates terminate, routers match hosts, each application shows its own login form, and the thing is reachable. The gap is what happens after that.

Every panel is its own authentication system. Six admin UIs means six login forms, six password policies, six ideas of what a failed login is worth logging, six lockout behaviours — and at least one of them has none. The weakest of the six sets the security of the set. Brute-force protection is not a property you get once; you get it per application, or not at all.

Credentials get reused across them. Nobody generates six distinct 24-character passwords for six panels they log into from the same laptop. So a credential leaked from the sloppiest application is a credential that works on the hypervisor.

forwardAuth collapses all of it into one login flow. Traefik asks an identity provider — here, authentik — whether the request carries a valid session before the request reaches the application at all. One password policy, one MFA enrolment, one session, one audit log. The application behind it never sees an unauthenticated request.

The IP allowlist is the second layer, and it is independent on purpose. Even if a credential is phished, the request never reaches the application unless it arrived from a trusted segment. Two controls that fail for different reasons is the whole point — a phished password does not move the attacker onto your network, and a foothold on your network does not hand them a session.

This is not paranoia. It is blast radius: one compromised panel should not be six.

Step 2: The two reusable middlewares
#

Everything below hangs off three snippets in Traefik’s dynamic configuration. Write them once; reference them from every admin router afterwards.

/etc/traefik/dynamic/security.yml:

http:
  middlewares:

    # SSO — hand the request to authentik's outpost for a verdict.
    authentik-forwardauth:
      forwardAuth:
        address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
        # authentik sits behind this same Traefik, so it must be told to
        # trust the X-Forwarded-* chain we are adding.
        trustForwardHeader: true
        authResponseHeaders:
          - X-authentik-username
          - X-authentik-groups
          - X-authentik-entitlements
          - X-authentik-email
          - X-authentik-name
          - X-authentik-uid
          - X-authentik-jwt
          - X-authentik-meta-jwks
          - X-authentik-meta-outpost
          - X-authentik-meta-provider
          - X-authentik-meta-app
          - X-authentik-meta-version

    # Network layer — only these source ranges may reach an admin UI.
    ipallowlist-admin:
      ipAllowList:
        sourceRange:
          - "192.0.2.0/24"      # trusted client segment
          - "203.0.113.0/24"    # second trusted segment
          - "127.0.0.1/32"

    # A default deny, for routers that should exist but answer nobody yet.
    deny-all:
      ipAllowList:
        sourceRange:
          - "127.0.0.1/32"

Three notes before anything is deployed.

ipAllowList is the modern spelling. Traefik renamed ipWhiteList to ipAllowList in v2.2; the old name still resolves in v2 but is gone in v3, and half the guides online still use it. If your config silently does nothing on Traefik v3, check which one you typed.

The authResponseHeaders list is what Traefik copies from the auth response onto the upstream request. authentik emits those header names in lowercase on the wire; HTTP header matching is case-insensitive, so the capitalisation above is correct and matches authentik’s own documentation. Applications that support header-based identity — the ones that can read a username out of a trusted header — get single sign-on for free from this list. Applications that don’t simply ignore them.

deny-all is not decoration. When you stand up a new router and haven’t decided who should reach it, attach deny-all rather than leaving it open “for a minute”.

Step 3: Deploy authentik
#

Three containers: server, worker, and Postgres. Redis is bundled into the server image from the 2024 releases onward, so this is smaller than older guides suggest.

docker-compose.yml:

services:
  postgresql:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - ./database:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: authentik
      POSTGRES_USER: authentik
      POSTGRES_PASSWORD: ${PG_PASS}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
      interval: 30s
      timeout: 5s
      retries: 5
    networks: [internal]

  authentik-server:
    image: ghcr.io/goauthentik/server:2026.2.0
    restart: unless-stopped
    command: server
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      # Which reverse-proxy source addresses may set X-Forwarded-For.
      # Narrow this to your proxy's Docker network, not the whole RFC1918 space.
      AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: "172.16.0.0/12"
    volumes:
      - ./media:/media
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy
    networks: [internal, proxy]

  authentik-worker:
    image: ghcr.io/goauthentik/server:2026.2.0
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
    volumes:
      - ./media:/media
      - ./certs:/certs
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy
    networks: [internal]

networks:
  internal:
  proxy:
    external: true

Generate the two secrets yourself and keep them in .env, out of version control:

echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env
chmod 600 .env

docker compose up -d
docker compose ps
# NAME                IMAGE                                    STATUS
# authentik-server    ghcr.io/goauthentik/server:2026.2.0      Up 24 seconds (healthy)
# authentik-worker    ghcr.io/goauthentik/server:2026.2.0      Up 24 seconds
# postgresql          postgres:16-alpine                       Up 31 seconds (healthy)

AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS is the one setting people skip and then spend an evening on. authentik only honours X-Forwarded-For and X-Forwarded-Proto from sources inside that list. Get it wrong and authentik believes every request came from the proxy’s own address — which breaks redirect URLs, breaks per-source policies, and makes every audit-log entry point at your reverse proxy.

Security caveat, and it is the important one in this step: authentik is now the thing that guards everything else, which makes it the highest-value target you run. Publish it behind the same IP allowlist as the panels it protects. An identity provider reachable from the whole internet, protecting admin UIs that are not, is a strictly worse arrangement than the one you started with.

Step 4: Initialize authentik and create the OIDC provider
#

Everything here is in the web UI, and it is short.

First run only, the initial setup flow lives at a fixed path — open https://auth.example.com/if/flow/initial-setup/, set the akadmin password, and do not skip the email field.

Then, in the admin interface:

  1. Applications → Providers → Create → Proxy Provider.
  2. Name it after the panel it fronts. Authorization flow: default-provider-authorization-implicit-consent for admin UIs you use daily — explicit consent on every login gets old fast.
  3. Mode: Forward auth (single application) for one host, or Forward auth (domain level) if every admin UI shares a parent domain and you want one session across all of them. Domain-level is the one you want here: log in once, reach all six panels.
  4. External host: https://admin.example.com for single-application mode, or the cookie domain (example.com) for domain-level.
  5. Applications → Applications → Create, bind it to that provider, and set a slug.
  6. Applications → Outposts → Create (or edit the built-in authentik Embedded Outpost), type Proxy, and select the applications it should serve. The embedded outpost is what answers on port 9000 in the compose stack above — no separate container needed.

If you also want a full OIDC provider — for applications that speak OIDC natively rather than trusting headers — create an OAuth2/OpenID Provider instead and copy the client ID and client secret it mints. Mint your own; never reuse a value from a guide:

# Application OIDC config — values come from YOUR authentik instance
client_id:     <your-client-id>
client_secret: REPLACE_ME
issuer:        https://auth.example.com/application/o/<your-app-slug>/

For the rest of this post the proxy provider is enough. forwardAuth does not need the application to know anything about OIDC at all — that is the reason it works on panels that have never heard of SSO.

Step 5: Wire the forwardAuth middleware into Traefik
#

The address in Step 2 points at the outpost over the Docker network, not over the public name. Both the Traefik container and authentik-server must be attached to the same Docker network — the proxy network in the compose file above — or the connection fails before authentication is even attempted.

Confirm the outpost answers from Traefik’s own network namespace before wiring any router to it:

docker exec traefik wget -qO- --server-response \
  http://authentik-server:9000/outpost.goauthentik.io/ping 2>&1 | head -3
#   HTTP/1.1 204 No Content

A 204 means the outpost is up and reachable. A DNS failure here means the two containers are on different networks; a connection refused means the embedded outpost was never enabled in Step 4.

Make sure Traefik is actually reading the dynamic file — a provider block in the static config, not a flag you meant to add:

# traefik.yml (static configuration)
providers:
  file:
    directory: /etc/traefik/dynamic
    watch: true

Then confirm Traefik parsed the middlewares. The dashboard lists them, but the API is faster and scriptable:

curl -s http://127.0.0.1:8080/api/http/middlewares | jq -r '.[].name'
# authentik-forwardauth@file
# ipallowlist-admin@file
# deny-all@file

The @file suffix is the provider name, and it is part of the reference. middlewares: [authentik-forwardauth] without it works only when the router comes from the same provider; spell it out and it works everywhere.

Step 6: The IP allowlist, and what it actually checks
#

ipAllowList.sourceRange matches the client IP of the TCP connection as Traefik sees it. That sentence is the whole feature, and where it bites is what “as Traefik sees it” means in your topology.

Traefik at the network edge — no CDN in front. The client IP is the literal source address of the connection. sourceRange does exactly what it reads like. This is the homelab case and the one this post assumes.

Traefik behind a CDN or another proxy. Every connection arrives from the CDN’s edge, so the client IP is a CDN address and your allowlist either blocks everyone or, if you add the CDN’s ranges, allows everyone on the internet who goes through that CDN. Neither is a control. The fix is to make Traefik derive the client IP from the forwarded headers, via the entrypoint’s forwarded-headers configuration:

# traefik.yml (static configuration)
entryPoints:
  websecure:
    address: ":443"
    forwardedHeaders:
      # ONLY the CDN edge ranges. Anything listed here can set its own
      # client IP, so a wildcard makes the allowlist decorative.
      trustedIPs:
        - "198.51.100.0/24"

With that in place, ipAllowList evaluates the address the CDN put in X-Forwarded-For rather than the CDN’s own. The security of the whole thing now rests on two conditions: the CDN overwrites X-Forwarded-For rather than appending to a client-supplied value, and nothing can reach your Traefik except through the CDN. Enforce the second at the firewall — if your origin is directly reachable, anyone can send a forged header and walk straight in.

The ranges in this post are documentation ranges. Substitute the segment your client machines actually live on, and keep the list short — an allowlist that covers most of your network is a comment, not a control:

RangeWhat it should be
192.0.2.0/24the segment your admin workstations sit on
203.0.113.0/24a second trusted segment, e.g. a VPN pool
127.0.0.1/32local health checks from the proxy host itself

Add your VPN’s client pool here rather than punching a hole for a roaming address. Remote access to admin panels should mean “connect the VPN first”, which is one rule instead of an ongoing editing habit.

Step 7: Bind both middlewares to every admin UI
#

The payoff. Each admin router grows one line.

# /etc/traefik/dynamic/routers-admin.yml
http:
  routers:

    traefik-dashboard:
      rule: "Host(`admin.example.com`)"
      entryPoints: [websecure]
      service: api@internal
      middlewares:
        - ipallowlist-admin@file
        - authentik-forwardauth@file
      tls:
        certResolver: letsencrypt

    internal-dashboard:
      rule: "Host(`dashboard.example.com`)"
      entryPoints: [websecure]
      service: internal-dashboard
      middlewares:
        - ipallowlist-admin@file
        - authentik-forwardauth@file
      tls:
        certResolver: letsencrypt

  services:
    internal-dashboard:
      loadBalancer:
        servers:
          - url: "http://192.0.2.30:8080"

The same two lines go on the hypervisor UI, the NAS, the container UI, the monitoring host. That is the entire cost of onboarding the next panel.

Order matters, and it is not obvious. Traefik executes middlewares in the order they are listed. With the allowlist first, a request from outside the trusted segment is rejected at the network layer and the chain stops — it never reaches forwardAuth, never opens a connection to authentik, never creates an authentication flow, never appears in the identity provider’s logs. Reverse the order and every scan of your public address becomes an authentication attempt: authentik allocates session state, issues a redirect that leaks your identity provider’s hostname and the target application’s slug, and writes an audit-log line, all for a request that was going to be 403’d a millisecond later.

Cheap rejections first. It is the same reason firewall rulesets put the deny at the top of the chain rather than the bottom.

Step 8: Prove it — the three-curl test
#

This is the part readers skip and then get wrong. Three requests, three different outcomes, and each one tests a different layer.

From a host inside the allowlist, with a valid SSO session:

curl -s -o /dev/null -w '%{http_code}\n' \
  --cookie "authentik_proxy=<session-cookie>" \
  https://dashboard.example.com/
# 200

From a host outside the allowlist — here, a public test address that is obviously not in any of the trusted ranges:

# Run from 198.51.100.77 — a public host, not in sourceRange
curl -si https://dashboard.example.com/ | head -5
# HTTP/2 403
# date: Thu, 10 Sep 2026 09:14:02 GMT
# content-length: 0
#

403, no body, no redirect, nothing that says what is behind the name. authentik was never consulted — there is no entry for this request in its log, which is exactly the behaviour Step 7’s ordering bought.

From a host inside the allowlist, with no valid session:

curl -si https://dashboard.example.com/ | head -6
# HTTP/2 302
# location: https://auth.example.com/outpost.goauthentik.io/start?rd=https%3A%2F%2Fdashboard.example.com%2F
# set-cookie: authentik_proxy=...; Path=/; HttpOnly; Secure; SameSite=Lax
# content-length: 0
#

The allowlist passed, forwardAuth returned a 401 to Traefik, and Traefik converted it into the outpost’s 302. The rd= parameter carries the original URL so the user lands where they were going after login.

Run all three after every change to the chain. The failure you are looking for is the second one returning 302 instead of 403 — that means the ordering regressed, or the allowlist is matching a proxy address rather than the client.

Step 9: Pitfalls and ops
#

trustForwardHeader: true is required, not optional. authentik sits behind the same Traefik that is calling it, so the proto and host it reconstructs come entirely from the X-Forwarded-* chain Traefik adds. Leave it false and authentik builds http:// redirect URLs for an https:// site, or redirects to the container name instead of the public host. The symptom is a login loop that never terminates and looks like a cookie problem.

The allowlist reads the source IP, not a header — until you tell it otherwise. Covered in Step 6 and worth repeating because it is the single most common way this design ends up providing no protection at all. Behind a CDN you either trust X-Forwarded-For from a narrow list of edge ranges and block direct access to your origin, or you use a CDN that supplies a signed or dedicated header — CF-Connecting-IP is the well-known example of that pattern — and plumb that through instead. A trusted-IP list containing 0.0.0.0/0 is worse than no allowlist, because it looks like one in the config.

Never put authentik itself behind forwardAuth. You will lock yourself out, and the recovery is a database edit or a fresh instance. The auth endpoint has to be reachable without authentication — that is what it is for. Put auth.example.com behind ipallowlist-admin@file only, and nothing else.

Extend the session lifetime before it annoys you into disabling the whole thing. authentik’s defaults are conservative and re-authenticating several times a day on a panel you keep open is how good controls get removed. Two places, both in the admin UI: the session length on the authentication flow’s binding, and Token validity on the proxy provider itself (Applications → Providers → your provider → Advanced protocol settings). Something in the range of hours=12 to days=7 is reasonable for a daily-driver admin UI. Pair a longer session with MFA rather than treating it as a replacement.

Back up the authentik database. It holds every user, every group, every provider, every flow, every application binding, and the outpost tokens. Lose it and you rebuild the entire identity layer by hand — not the containers, which are one docker compose up away, but every policy decision you made along the way. A nightly pg_dump to a volume that is itself backed up costs nothing:

docker compose exec -T postgresql \
  pg_dump -U authentik authentik | gzip > "authentik-$(date +%F).sql.gz"

Keep the AUTHENTIK_SECRET_KEY with the dump — it encrypts fields inside the database, and a restore without it recovers rows you cannot read. Test the restore once, on a throwaway stack, before you need it.

What this buys you, and what it doesn’t
#

Two independent controls now stand in front of every admin panel. A leaked password is not enough, because the request has to originate from a trusted segment. A foothold on the network is not enough, because the request still has to carry a valid session. Onboarding the next panel is two lines of YAML, and the panel’s own login page — whatever it is, however bad it is — is no longer internet-reachable.

What this does not do:

  • It is not multi-factor authentication. It is one factor, applied consistently. authentik ships TOTP and WebAuthn stages out of the box; add one to the authentication flow and the phished-credential scenario stops working entirely. That is the highest-value next change you can make.
  • It does not rate-limit anything. Login attempts now concentrate on authentik, which makes authentik the thing worth brute-forcing. A rateLimit middleware belongs in front of it, ahead of the allowlist in the chain.
  • The IP allowlist is a network-layer control. It says nothing about who is connecting, only from where. Anyone on the trusted segment — a guest laptop, a compromised IoT device that ended up in the wrong VLAN — clears it. It reduces blast radius; it does not establish identity. That is forwardAuth’s job, and the reason both layers exist.
  • Header-based identity is only as trustworthy as the path. The X-authentik-* headers are safe because Traefik sets them on a connection the application only ever receives from Traefik. Expose the application’s port directly and anyone can forge them.

Next in this series: hardening the reverse proxy itself — security headers, HSTS with preload, and feeding Traefik’s access log to fail2ban so the scans that reach the allowlist stop reaching it twice.

 Author
Author
Wassim Bejaoui
Security Engineer
Homelab Security - This article is part of a series.
Part 1: This Article

Related