Authelia or Authentik: Putting SSO Forward-Auth in Front of Self-Hosted Services
What forward-auth is, when Authelia is the right tool and when authentik is – plus a complete minimal example with Authelia 4.39.20 behind Traefik v3.7, including the cookie domain, middleware and session pitfalls.
Plenty of self-hosted services ship with no user management at all: a Prometheus, an internal monitoring frontend, a Traefik dashboard, some admin tool you put up in a hurry. As long as it only runs on the LAN, that is tolerable. The moment the service is reachable from the internet under a domain, you need a doorman in front of it – and one you do not have to rebuild for every single service. That is exactly what forward-auth does. This post explains the core idea, gives a short and honest comparison of the two common candidates Authelia and authentik, and then stands up a complete, working minimal example with Authelia behind Traefik. It assumes the Traefik basics are already in place.
What forward-auth does – and the core idea
Forward-auth is a middleware pattern in the reverse proxy. When a request arrives, Traefik does not send it straight to the backend; it first issues a sub-request to an auth endpoint. If that endpoint answers with a 2xx status, the original request is passed through; any other response is returned to the client unchanged – in practice usually a redirect to the login page. That is precisely how the Traefik documentation describes the forwardAuth middleware.
Traefik hands the auth service the context of the original request in X-Forwarded- headers: X-Forwarded-Method, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Uri and X-Forwarded-For. From host and path the auth service derives which access rule applies. In return, on success it may send back headers that Traefik forwards to the backend via authResponseHeaders – with Authelia those are Remote-User, Remote-Groups, Remote-Email and Remote-Name. Applications that support header authentication therefore know the signed-in user without a login of their own.
The decisive point: the protected application is never touched. It needs neither modification nor any awareness of what happened.
Client Traefik Authelia app
| GET app.example.com | | |
|---------------------->| /api/authz/forward-auth |
| |---------------------->| |
| | 302 auth.example.com | |
| 302 -> login page |<----------------------| |
|<----------------------| | |
| ... login, session cookie for .example.com ... |
| GET app.example.com | | |
|---------------------->|---------------------->| |
| | 200 + Remote-User | |
| |<----------------------| |
| |-------------------------------------->|
| 200 content |<--------------------------------------|
|<----------------------| | |
Authelia or authentik?
Both handle forward-auth with Traefik. What separates them is not the details but the ambition.
Authelia – currently 4.39.20, released on 26 May 2026 – is a single Go binary with one YAML configuration file. Users come either from a plain YAML file or from LDAP; a local SQLite file is enough as a database. There is no admin UI, you edit files. In exchange, operational overhead is minimal and the entire configuration lives in your Git repository where you can version it.
authentik – currently 2026.5.6, released on 22 July 2026 – is a full identity provider with a web UI, a graphical flow editor, SAML, OIDC, SCIM and an LDAP outpost. The documentation states a minimum of a host with two CPU cores and 2 GB of RAM, plus PostgreSQL. The dependency list has at least got shorter recently: since version 2025.10 Redis is gone entirely, because caching, tasks, the embedded outpost and WebSockets were migrated to Postgres.
The recommendation is unspectacular but clear: if you want to keep a handful of self-hosted services away from strangers, and configuration-as-a-file-in-the-repo is your working model anyway, take Authelia. As soon as real federation enters the picture – SAML against a business partner, SCIM provisioning, end-user self-service, access policies that no longer fit into a handful of YAML lines – authentik is the better tool and the extra operational effort is justified. The rest of this post shows the Authelia route.
The minimal example: the configuration
A warning first: Authelia has changed its configuration format several times across versions, in particular the session and secret keys. The example below is verified against the configuration template of Authelia 4.39.20; older guides you find online frequently still use the old flat session format without a cookies list, or a top-level jwt_secret. Both have been deprecated since 4.38: Authelia still maps such legacy configurations onto the new format automatically at startup and logs a deprecation warning – but mixing the old and the new session format causes a hard startup error.
For the container image the file lives at /config/configuration.yml:
log:
level: 'info'
server:
address: 'tcp://:9091/'
identity_validation:
reset_password:
jwt_secret: '<PLACEHOLDER_JWT_SECRET_64_CHARS>'
authentication_backend:
file:
path: '/config/users_database.yml'
watch: true
password:
algorithm: 'argon2'
access_control:
default_policy: 'deny'
rules:
- domain: 'app.example.com'
policy: 'one_factor'
session:
secret: '<PLACEHOLDER_SESSION_SECRET_64_CHARS>'
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
default_redirection_url: 'https://www.example.com'
name: 'authelia_session'
same_site: 'lax'
inactivity: '15m'
expiration: '8h'
remember_me: '1M'
regulation:
max_retries: 3
find_time: '2m'
ban_time: '5m'
storage:
encryption_key: '<PLACEHOLDER_STORAGE_ENCRYPTION_KEY_64_CHARS>'
local:
path: '/config/db.sqlite3'
notifier:
filesystem:
filename: '/config/notification.txt'
You generate the three placeholders with the bundled CLI:
docker run --rm authelia/authelia:4.39.20 authelia crypto rand --length 64 --charset alphanumeric
The user database is a second YAML file, /config/users_database.yml:
users:
emre:
disabled: false
displayname: 'Emre Yurtbay'
password: '<PLACEHOLDER_ARGON2ID_HASH>'
email: 'emre@example.com'
groups:
- 'admins'
The same container produces the hash – Argon2id is the default:
docker run --rm -it authelia/authelia:4.39.20 authelia crypto hash generate argon2
The output starts with $argon2id$v=19$... and is copied in full, inside single quotes, into the password field.
The minimal example: Traefik and the protected service
Now the stack. Traefik takes on two roles: it publishes the Authelia portal at auth.example.com and it defines the forward-auth middleware that can then be attached to any number of services.
networks:
proxy:
services:
traefik:
image: traefik:v3.7
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
# Only set this if Traefik itself sits behind another proxy:
# - "--entrypoints.websecure.forwardedheaders.trustedips=10.0.0.0/8"
- "--certificatesresolvers.le.acme.email=admin@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.tlschallenge=true"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt/acme.json:/letsencrypt/acme.json"
networks: [proxy]
restart: unless-stopped
authelia:
image: authelia/authelia:4.39.20
volumes:
- "./authelia:/config"
networks: [proxy]
restart: unless-stopped
labels:
- "traefik.enable=true"
# The portal itself - an ordinary route, WITHOUT the auth middleware
- "traefik.http.routers.authelia.rule=Host(`auth.example.com`)"
- "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls.certresolver=le"
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
# The middleware, defined once here
- "traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth"
- "traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia.forwardAuth.maxResponseBodySize=8192"
- "traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Email,Remote-Name"
app:
image: traefik/whoami
networks: [proxy]
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.routers.app.middlewares=authelia@docker"
- "traefik.http.services.app.loadbalancer.server.port=80"
Starting it:
mkdir -p authelia letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
docker compose up -d
Calling https://app.example.com now lands on the sign-in form at auth.example.com. After the login the whoami output appears – and in its header list you will see Remote-User: emre and Remote-Groups: admins. That is the whole trick, and traefik/whoami is the ideal test service for exactly that reason: it shows you what actually arrives at the backend.
Three common pitfalls
1. The cookie domain and authelia_url have to match. The session cookie is set on session.cookies[].domain. Per the documentation, the value in authelia_url must either match that host exactly or – when prefixed with . – be a suffix of it. So domain: 'example.com' and authelia_url: 'https://auth.example.com' fit together; domain: 'auth.example.com' while protecting app.example.com does not. The protected service has to live under that domain too, otherwise the browser never sends the cookie – and you end up in an endless loop of login and redirect. Second trigger for the very same loop: accidentally attaching the authelia@docker middleware to the Authelia router itself. The portal has to stay reachable without authentication.
2. Provider namespace and middleware order. A middleware defined through Docker labels lives in the Docker provider's namespace and is correctly referenced as authelia@docker – without the suffix it only works within the same provider. More important still: Traefik applies middlewares in the order in which they are declared. With middlewares=stripprefix@docker,authelia@docker Authelia sees the already shortened path; in the reverse order it sees the original one. Since your access_control rules match on host and path, that order decides between access granted and access denied. A side note that still matters: trustForwardHeader is marked deprecated in Traefik v3.7 and will be removed in the next major version. Set it explicitly for now anyway – without it Traefik logs a warning at startup and falls back to legacy behaviour. The clean combination is forwardedHeaders.trustedIPs at the entrypoint (leave it unconfigured when Traefik itself is the edge: then no client counts as trusted and all incoming X-Forwarded- headers are stripped) plus trustForwardHeader: true on the middleware.
3. Sessions and storage are not where you think they are. Authelia's default session provider is memory. A docker compose restart therefore throws out every signed-in user. For anything beyond a test run – and unconditionally as soon as you run more than one Authelia instance – a Redis provider belongs underneath. Equally important: /config must be a real volume or bind mount, otherwise the SQLite file, and with it every TOTP enrolment, disappears along with the container. And storage.encryption_key cannot simply be edited in the file after the fact; it encrypts columns in the database and has to be rotated through the CLI (authelia storage encryption change-key). So get it right the first time.
Where to go from here
You now have an access layer that costs exactly one line per additional service: traefik.http.routers.<name>.middlewares=authelia@docker. The obvious next steps are a second factor via TOTP or WebAuthn (policy: 'two_factor' instead of one_factor), finer rules using subject and networks, and an LDAP backend instead of the YAML file. For more depth, see Authelia's Traefik integration guide, the access control reference and the Traefik documentation on the ForwardAuth middleware. And if you are still choosing a proxy: the Caddy vs. Traefik comparison puts into context when the label-driven approach is worth it in the first place.
Publication note: This article was scheduled for 6 August 2026. Because of a technical fault in our publishing automation, it did not go live until 11 August 2026. All information was re-checked for accuracy before publication.
Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).