Authelia SSO pitfalls: a policy protects nothing if the route skips it
Blog · sso

Authelia SSO pitfalls: a policy protects nothing if the route skips it

Missing forward-auth middleware, cookie domains without a dot, an OIDC refresh-token flood and two sources of truth

My single sign-on configuration said three public services required two-factor authentication. All three answered anonymous visitors with HTTP 200. The policy was correct. It just never ran, because the requests never reached it.

I run Authelia as the SSO portal for my self-hosted services, in front of Traefik on a single-node k3s cluster. Over six months I collected a set of pitfalls that are not in the getting-started guides: policies that protect nothing, cookie domains that cannot work, an OIDC client that flooded the Kubernetes API with 42,671 rejected tokens, and tooling that “helpfully” protected a door that had to stay open. This is the list, with the fixes. Every issue here is closed.

Contents · 12 sections

TL;DR #

  • With forward-auth, the access rule only applies if the route sends the request to Authelia. A middleware defined but not attached means the service is open, whatever the policy says.
  • Check exposure per domain from the outside: anonymous curl must get a redirect to the login page.
  • Authelia session cookies need a domain with at least one dot. A single-label internal domain cannot carry SSO.
  • Services with their own login (mail clients, meeting links) must bypass the portal, but split their admin consoles out first.
  • OIDC clients that need long sessions must request offline_access and the refresh_token grant, or they will retry a dead token forever.
  • If a tool generates your ACL, the tool is the source of truth. Hand edits get silently overwritten.
  • Mandatory 2FA was the boundary that held when real passwords leaked.

How forward-auth works, and how it fails #

Traefik receives the request. If the route has the forward-auth middleware, Traefik first sends a sub-request to Authelia with the original host and path. Authelia checks the session cookie against its access-control rules and answers 200 (let it through, with identity headers), 401/302 (go log in) or 403. Only then does Traefik forward the request to the app.

So there are two independent pieces of configuration:

  1. the route, which decides whether Authelia is asked at all;
  2. the access rule, which decides what Authelia answers.

If the first piece is missing, the second never runs.

Pitfall 1: the middleware that was defined but never attached #

In July 2026 I cross-checked every domain that my SSO management tool had registered as protected, 115 of them, against the live Traefik routes. Three public services, an AI chat interface, a document-search app and a certificate portal, were registered as two-factor and served pages to anyone.

Each route file defined the forward-auth middleware. None of the routes referenced it:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: <app>
spec:
  routes:
    - match: Host(`<app>.example.com`)
      kind: Rule
      middlewares:
        - name: authelia-forwardauth   # this line was missing
      services:
        - name: <app>
          port: 80

My tool’s consistency check had verified that each registered domain had a route. It never checked that the route carried the middleware. I attached the middleware, confirmed that anonymous requests now got a 302 to the login page, and added a per-domain auth-exposure guardrail that alerts whenever a domain registered as protected has a live route without the middleware. It fired on its first run and has been at zero since.

The test that matters is the one an attacker would run:

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://<app>.example.com/
# expected: 302 https://auth.example.com/?rd=...

Pitfall 2: checking the backend instead of the domain #

The first version of the guardrail produced false alarms, and they taught me something. Some Kubernetes services serve several domains with different policies: the mail server serves a public autoconfiguration domain and an admin console; my MCP server has a public and an internal name. The checker grouped everything by backend, so a sibling domain with a different policy looked like drift.

The same bug existed in the tool’s auto-protect feature, which adds the middleware to new routes: it decided by backend, and added a redundant middleware to a domain that was deliberately open. Both now decide per domain, by the service that owns that domain.

Pitfall 3: services that have their own login #

Not everything belongs behind the portal. Mail clients on phones speak IMAP, SMTP and JMAP; they cannot click through an SSO page. Video meeting join links must work for guests. Gating those breaks them. They get a bypass rule and rely on their own authentication.

But the first time I set the whole mail service to bypass, the rule also swept up the mail server’s web admin console, which had been behind two-factor. The fix was to split the service: the admin domain became its own service with a two-factor policy for admins, and only the protocol and autoconfiguration endpoints stayed bypassed. When you bypass a service, list every domain and path it owns first.

For an app whose UI needs a login but whose API is authenticated with keys, the right tool is a path rule on the same domain, not a second route:

access_control:
  default_policy: deny
  rules:
    - domain: <app>.example.com
      resources: ['^/api/.*', '^/mcp.*']
      policy: bypass          # the app checks its own API keys
    - domain: <app>.example.com
      policy: two_factor
      subject: ['group:admins']

Rules are evaluated in order, so the bypass for the API paths has to come first.

My internal services originally lived under a single-label domain, the kind of .lan or .home name many home networks use. Authelia refused to start with it as a session cookie domain: the domain must contain at least one period or be an IP address. Browsers apply a similar rule, because a cookie for a bare top-level label would be shared with every site under it.

The fix was a two-level internal domain. I moved over 60 routes and about 95 references in code to the new name, ran both names in parallel, and switched with no downtime. If you are designing a home-lab domain now, pick a subdomain of a domain you own from the start.

Pitfall 5: an OIDC client with no refresh token #

Authelia is also my OpenID Connect provider, including for the Kubernetes dashboard, which logs in to the API server with OIDC tokens. In August I found 42,671 “token is expired” rejections in the API server log, about four a minute for seven days.

The dashboard’s ID token had expired 13 minutes after the cluster started. Its client was registered with the scopes openid email profile groups, without offline_access and without the refresh-token grant, so Authelia never issued a refresh token. The dashboard could not renew its session, and kept presenting the dead token until the browser tab was closed.

identity_providers:
  oidc:
    clients:
      - client_id: <dashboard>
        scopes: [openid, email, profile, groups, offline_access]
        grant_types: [authorization_code, refresh_token]
        response_types: [code]   # offline_access is only honoured for code flows

One more rule from that day: validate the configuration before restarting. Authelia is a single point of failure for every app that uses it; a typo here takes down every login at once.

authelia validate-config --config /config/configuration.yml

Pitfall 6: two sources of truth #

I manage users, groups and per-service policies in a small web tool of my own, backed by a database. It generates Authelia’s ACL file and applies it, with backup, validation, reload, a Git commit and an audit log.

An AI assistant working on my lab once edited the generated ACL by hand to add a quick rule. The tool’s database did not have that grant, so its next apply would have silently removed it. Another time it created a new route by hand for an API that needed key-only access; the tool’s scheduler, which syncs every five minutes, registered it as a protected service and added the middleware, and after a messy cleanup the host returned 403 to everyone for some minutes.

The rule now: if a tool generates the configuration, every change goes through the tool. The generated file says “do not edit” in its header for a reason. And when a tool rewrites manifests in a shared Git working tree, read the diff of every staged file before committing, including files you did not touch. One of its automatic edits once put the SSO portal in front of an emergency entrance that had to stay reachable without it, and the change was committed unnoticed in someone else’s commit.

The boundary that held #

In May 2026 my SSH honeypot recorded attackers trying passwords that I recognised: real passwords, leaked from an old, exposed admin panel and from reuse. Checking them against Authelia’s password hashes showed that 4 of 6 accounts used one of them.

Nothing was breached. Every service behind Authelia requires a second factor, TOTP or a hardware key, so a password alone has never been enough to log in. I rotated all four passwords anyway, as defence in depth, and moved every new credential into a password manager. Two-factor turned a serious leak into a chore.

Checklist #

  • Every protected route references the forward-auth middleware; defining it is not enough.
  • An automated check compares registered protection with live routes, per domain, and alerts on differences.
  • Test from outside: anonymous request returns a redirect to the login page.
  • default_policy: deny, then explicit rules.
  • Bypass only self-authenticated endpoints, and split out their admin consoles first.
  • Use path rules for key-authenticated APIs on a protected domain.
  • Session cookie domain has at least one dot.
  • Long-lived OIDC clients have offline_access and the refresh_token grant.
  • Run authelia validate-config before every restart.
  • One source of truth for access rules; review diffs of generated files.
  • Mandatory 2FA for every user.

Frequently asked questions #

Why is my Authelia-protected service still accessible without login? #

Most often the Traefik route does not reference the forward-auth middleware, so Authelia is never asked. Check the live route, not the manifest you think was applied, and test with an anonymous request.

Session cookie domains must contain at least one period or be an IP address. A single-label name cannot carry a cookie safely. Use a subdomain such as home.example.com.

Why does my OIDC client keep sending expired tokens? #

It probably has no refresh token. Add offline_access to its scopes and the refresh_token grant type, use the authorization code flow, and restart both the provider and the client.

Should mail and video meeting services sit behind SSO? #

Their protocol endpoints usually should not: mail clients and guest join links cannot complete an SSO login. Bypass those endpoints, keep their admin interfaces protected, and rely on the services’ own authentication.

The lesson #

An access rule is a promise, and a route is the delivery. My configuration promised two-factor everywhere, and three times the delivery was missing. The fixes were never clever: attach the middleware, test from the outside, decide per domain, keep one source of truth, and validate before restarting. And when real passwords leaked, the least clever control of all, a mandatory second factor, was the one that held.