Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Advanced

Authentik Part 2: Connecting Apps – Forward Auth & OIDC

Attach services to Authentik: forward auth with Traefik for apps without a login, and OpenID Connect for everything with its own SSO support – all verified.

· 13 min read ·Duration: approx. 75 minutes
Table of contents

Authentik is running – now comes the part that justifies the effort: the services behind it. There are exactly two routes, and the choice depends solely on whether the application understands SSO itself.

What are we building?

We attach two services, each the appropriate way:

  • Forward auth for an application without its own login. Traefik asks Authentik before every request whether the visitor is signed in. If not, they land on the Authentik login page; if they are, the application receives their identity as HTTP headers.
  • OpenID Connect (OIDC) for applications with SSO support – Grafana, Nextcloud, Forgejo, Immich and many others. There you enter client ID, secret and the endpoints, and the app does the rest.

Everything below was carried out with Authentik 2026.8 behind Traefik v3.7 on Debian 13: at the end the test service shows the forwarded identity headers, and the OIDC discovery document returns real endpoints.

Which route for which service?

The decision is almost always clear-cut:

ServiceSupports OIDC?Route
Nextcloud, Grafana, Forgejo, Immich, PaperlessyesOIDC – real accounts, roles from groups
Uptime Kuma, Dockge, Netdata, a static dashboardnoforward auth
Metrics endpoints, /actuator, internal APIsirrelevantforward auth or, simpler: not public (VPN)
Services with a mobile app (Immich, Nextcloud)yesOIDC only – forward auth breaks the apps (see below)

In short: OIDC where possible, forward auth where necessary. OIDC gives the application a real user account including roles; forward auth only puts a door in front of it and passes a name through in a header.

Prerequisites

  • A running Authentik from part 1, reachable at https://YOUR_DOMAIN
  • A Traefik v3 with a proxy network (guide) – we work a lot with middlewares here, the basics are in hardening Traefik
  • A service to practise on. We use traefik/whoami because it echoes every header it receives – which makes it immediately obvious whether the integration really works

One domain or two?

We show the setup on one domain: Authentik at https://YOUR_DOMAIN/, the protected service at https://YOUR_DOMAIN/whoami. That is the frugal variant and exactly how it was tested. More common is the split (auth.YOUR_DOMAIN for Authentik, app.YOUR_DOMAIN for the service) – the steps are identical, only external_host and the Traefik rules then carry the other hostname.

Step by step

1. Access for automation: an API token

Providers and applications can be created entirely through the API – handy, because it makes your work reproducible instead of click-based. For that, Authentik needs a token.

Because you went through the setup wizard in part 1, akadmin already exists. So you create the token in the interface: Directory → Tokens and App passwords → Create token. Use a descriptive identifier (api-setup), set the intent to API and the user to akadmin. After saving, copy the key via the copy icon – it is shown only once.

On a fresh installation it can be automatic

If you set Authentik up from scratch, you can have the account and token created on the very first start – no wizard needed. The variables belong on the worker service (not on server) and are only read on the first startup; on an existing installation they have no effect. Instead of the plaintext password, Authentik now recommends a hash. hash_password does not take the password as an argument – hash_password YOUR_PASSWORD aborts with unrecognized arguments – but via a prompt or standard input:

Terminal
docker compose run --rm server hash_password

At the end you get a line starting with pbkdf2_sha256$. It goes into the .env as AK_BOOTSTRAP_PASSWORD_HASH, the token as AK_BOOTSTRAP_TOKEN (for example from openssl rand -hex 32). In compose.yaml the worker then looks like this:

YAML
  worker:
    environment:
      AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: ${AK_BOOTSTRAP_PASSWORD_HASH}
      AUTHENTIK_BOOTSTRAP_TOKEN: ${AK_BOOTSTRAP_TOKEN}
      AUTHENTIK_BOOTSTRAP_EMAIL: you@YOUR_DOMAIN

The hash contains $ characters: wrap it in single quotes in the .env, and write each $ as $$ when putting it directly into compose.yaml.

Verify the token – no matter which route produced it:

Terminal
curl -s -H "Authorization: Bearer YOUR_LONG_RANDOM_TOKEN" \
  https://YOUR_DOMAIN/api/v3/core/users/me/
Ausgabe
{"user":{"username":"akadmin", … "is_superuser":true, …}}

Warning

The bootstrap token is a full admin access without a second factor. It belongs in the .env with chmod 600, not in a script in your repository – and if you only need it for setup, delete it afterwards under Directory → Tokens.

2. Route A: forward auth for services without a login

The proxy provider is Authentik’s answer to „this app has no user management at all". It comes in several modes; we use forward_single: one application, one provider.

First the IDs of the two default flows (known from part 1):

Terminal
API=https://YOUR_DOMAIN/api/v3
TOK="Authorization: Bearer YOUR_LONG_RANDOM_TOKEN"

AUTHZ=$(curl -s -H "$TOK" "$API/flows/instances/?slug=default-provider-authorization-implicit-consent" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['results'][0]['pk'])")
INVAL=$(curl -s -H "$TOK" "$API/flows/instances/?slug=default-provider-invalidation-flow" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['results'][0]['pk'])")

Then the provider:

Terminal
curl -s -X POST -H "$TOK" -H "content-type: application/json" "$API/providers/proxy/" -d "{
  \"name\": \"whoami-forwardauth\",
  \"authorization_flow\": \"$AUTHZ\",
  \"invalidation_flow\": \"$INVAL\",
  \"mode\": \"forward_single\",
  \"external_host\": \"https://YOUR_DOMAIN\"
}"

And the application that uses it – it also creates the tile in the user library:

Terminal
curl -s -X POST -H "$TOK" -H "content-type: application/json" "$API/core/applications/" -d '{
  "name": "whoami (protected)", "slug": "whoami", "provider": 1
}'

The 1 is the pk from the provider call’s response. Both then appear in the overview:

The application list in Authentik with two entries: Grafana through an OAuth2/OpenID provider and whoami through a proxy provider
Two applications, two routes: OIDC provider and proxy provider

3. Arm the outpost – and the most common trap

A proxy provider does not work alone: an outpost serves it. Authentik ships an embedded outpost that runs inside the server container – you only have to assign the provider to it:

Terminal
OUT=$(curl -s -H "$TOK" "$API/outposts/instances/" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['results'][0]['pk'])")
curl -s -X PATCH -H "$TOK" -H "content-type: application/json" \
  "$API/outposts/instances/$OUT/" -d '{"providers": [1]}'

Without authentik_host your users land on localhost

The embedded outpost does not know its public address by itself. In the test the authentik_host field was empty, and an unauthenticated visitor was redirected to http://localhost/application/o/authorize/… – a dead page for every user. Set it once and the redirect is correct:

Terminal
curl -s -X PATCH -H "$TOK" -H "content-type: application/json" \
  "$API/outposts/instances/$OUT/" \
  -d '{"config": {"authentik_host": "https://YOUR_DOMAIN/"}}'

Afterwards the redirect points at https://YOUR_DOMAIN/application/o/authorize/… – measured before and after. In the interface the same field lives under Applications → Outposts → Edit.

4. Traefik: the forward auth middleware

Now the connection is missing. Traefik asks the outpost before each request – that is what the forwardauth middleware does. On the protected service:

YAML
  whoami:
    image: traefik/whoami:v1.12
    restart: unless-stopped
    networks: [proxy]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`YOUR_DOMAIN`) && PathPrefix(`/whoami`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=le"
      - "traefik.http.routers.whoami.middlewares=ak-auth"
      - "traefik.http.services.whoami.loadbalancer.server.port=80"
      - "traefik.http.middlewares.ak-auth.forwardauth.address=http://server:9000/outpost.goauthentik.io/auth/traefik"
      - "traefik.http.middlewares.ak-auth.forwardauth.trustForwardHeader=true"
      - "traefik.http.middlewares.ak-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"

The three decisive lines:

  • address points at the outpost – here through the service name server (Authentik reaches it under this alias on the Docker network) on the shared Docker network, so without a detour over the internet. The path /outpost.goauthentik.io/auth/traefik is the Traefik-specific check endpoint.
  • trustForwardHeader=true passes the X-Forwarded-* headers along so Authentik knows the originally requested address and can redirect back correctly.
  • authResponseHeaders is the actual benefit: Traefik copies these headers from Authentik’s response into the request to your application. Without that line the service is protected but has no idea who is there.

5. Verify that it really protects

First without a login – curl is enough:

Terminal
curl -s -o /dev/null -w "%{http_code} → %{redirect_url}\n" https://YOUR_DOMAIN/whoami
Ausgabe
302 → https://YOUR_DOMAIN/application/o/authorize/?client_id=…&redirect_uri=…%2Foutpost.goauthentik.io%2Fcallback…

A 302 to the Authentik login is the desired result. In the browser the login page appears before the service:

The Authentik login page shown when opening the protected service
Not signed in: Authentik asks before the service answers

After logging in you are back at /whoami – and the service shows what it receives:

Ausgabe
GET /whoami HTTP/1.1
Host: YOUR_DOMAIN
X-Authentik-Email: you@YOUR_DOMAIN
X-Authentik-Groups: authentik Admins
X-Authentik-Name: authentik Default Admin
X-Authentik-Uid: d216e191a6f972c39931a005a7998a68…
X-Authentik-Username: akadmin
X-Forwarded-Proto: https

That is the whole trick: the application has not a single line of login code – and still knows who is sitting in front of it. Services that support header authentication (Grafana with auth.proxy, Paperless, Forgejo with reverse-proxy auth) can turn that straight into a session.

Headers are only as safe as their path

If an application trusts X-Authentik-Username, that header must never be able to come from outside. Hence: the service is reachable exclusively through Traefik (no published port), and the middleware is attached to every router leading to it. A second router without the middleware is an open door.

6. Know the limits of forward auth

Before you put everything behind forward auth: the mechanism assumes a browser. An unauthenticated request gets a 302 redirect to a login form – and that is exactly what breaks anything that is not a browser:

  • Mobile apps (Nextcloud, Immich, Audiobookshelf) cannot do anything with the redirect. They see HTML instead of JSON and report „server unreachable". For such services OIDC is the only sensible route – or you exempt the app’s paths deliberately.
  • API access and scripts (curl, backup jobs, webhooks) run into nothing. Wherever a service is addressed by token, that path has to bypass the middleware – or you move the protection to an IP allowlist.
  • WebDAV and sync clients behave like APIs, with the same outcome.

For such exceptions you create a second router without the auth middleware that serves only the necessary path – and protect it differently, for example with the application’s own token. Example for an API path:

YAML
      - "traefik.http.routers.app-api.rule=Host(`YOUR_DOMAIN`) && PathPrefix(`/api`)"
      - "traefik.http.routers.app-api.entrypoints=websecure"
      - "traefik.http.routers.app-api.tls.certresolver=le"
      - "traefik.http.routers.app-api.middlewares=rate@file"

Warning

Every such exception router is a deliberate hole in your protection. Keep the path as narrow as possible (/api, not /), and write down why it exists – in six months nobody will remember otherwise.

For completeness, the third mode of the proxy provider: forward_domain. With it one outpost serves all services of a domain (*.YOUR_DOMAIN), and users sign in once for all of them. That is the more convenient setup from roughly three services onwards, but it requires Authentik and all applications to live under the same parent domain so the session cookie can be shared. What is tested here is forward_single – switching only affects the provider’s mode and the cookie domain.

7. Route B: OpenID Connect for services with their own SSO

If the application has an OIDC login, that is the better route: it then keeps real user accounts including roles instead of just seeing a name in a header.

Create the provider – what matters is the redirect URI, which must match the application’s callback address exactly (Grafana as an example):

Terminal
curl -s -X POST -H "$TOK" -H "content-type: application/json" "$API/providers/oauth2/" -d "{
  \"name\": \"grafana-oidc\",
  \"authorization_flow\": \"$AUTHZ\",
  \"invalidation_flow\": \"$INVAL\",
  \"client_type\": \"confidential\",
  \"redirect_uris\": [{\"matching_mode\": \"strict\", \"url\": \"https://GRAFANA_DOMAIN/login/generic_oauth\"}]
}"

The response contains client_id and client_secret – you need both in the application. Then create an application pointing at this provider, as above.

Whether everything lines up is revealed by the discovery document. Authentik publishes it per application under its slug:

Terminal
curl -s https://YOUR_DOMAIN/application/o/grafana/.well-known/openid-configuration \
  | python3 -m json.tool | head -12
Ausgabe
"issuer": "https://YOUR_DOMAIN/application/o/grafana/",
"authorization_endpoint": "https://YOUR_DOMAIN/application/o/authorize/",
"token_endpoint": "https://YOUR_DOMAIN/application/o/token/",
"userinfo_endpoint": "https://YOUR_DOMAIN/application/o/userinfo/",

Note: the issuer is application-specific (it contains the slug), while the remaining endpoints are the same for all applications. Exactly these addresses – or the discovery URL itself if the app supports it – go into the application. For Grafana, for instance:

INI
GF_AUTH_GENERIC_OAUTH_ENABLED=true
GF_AUTH_GENERIC_OAUTH_NAME=Authentik
GF_AUTH_GENERIC_OAUTH_CLIENT_ID=YOUR_CLIENT_ID
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=YOUR_CLIENT_SECRET
GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://YOUR_DOMAIN/application/o/authorize/
GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://YOUR_DOMAIN/application/o/token/
GF_AUTH_GENERIC_OAUTH_API_URL=https://YOUR_DOMAIN/application/o/userinfo/

Groups as roles

Most applications can derive roles from a field in the token – Grafana for example via role_attribute_path. That lets you steer admin rights centrally through Authentik groups instead of maintaining them in every app. Which is exactly why part 1 advised attaching permissions to groups rather than individual users.

8. Who may do what? Restricting access

As long as no policy is attached to an application, every signed-in user may use it. For the normal case you bind a group to the application: Applications → your application → policy / group / user bindings, then pick the group.

Only after that does the tile disappear from the user library for everyone else – and access is blocked. The most honest way to verify is with a second test account that is not in the group: it should neither see nor be able to open the application.

When things go wrong

The script aborts while looking up the flows with IndexError: list index out of range. This only happens right after a fresh installation: /-/health/ready/ already reports 200 while the worker is still importing the bundled flows. In our test there was only one flow at that moment, all 15 were there 20 to 30 seconds later. Wait briefly and run it again.

The request redirects to http://localhost/application/o/authorize/…. The embedded outpost does not know its public address: the authentik_host field is empty. Set it to https://YOUR_DOMAIN/ (step 3) – before that every user lands on a dead page.

Traefik answers with 500, or no redirect happens at all. Then the middleware cannot reach the outpost. The address has to be resolvable from Traefik’s point of view – so the container name on the shared network (http://server:9000/outpost.goauthentik.io/auth/traefik), not localhost. From the Traefik folder, check with docker compose exec traefik wget -qO- http://server:9000/-/health/live/ whether the connection works.

The service is protected but does not know the user. authResponseHeaders is missing from the middleware. Without that list Traefik discards the identity headers from Authentik’s response – the login works, but the application sees nothing.

After signing in you end up in a redirect loop. Usually external_host does not match the address actually being called (http instead of https, with or without a subdomain). It has to be exactly the URL users open the service at.

The OIDC login fails with „redirect_uri mismatch". Authentik checks the callback address strictly. Enter exactly the URI the application sends – for Grafana e.g. https://GRAFANA_DOMAIN/login/generic_oauth. A missing trailing slash is enough to break it.

All signed-in users get in everywhere. Without a binding to a group an application is open to every authenticated user. Attach a group binding to the application (step 8) and verify with an account that is not in the group.

Your problem is not listed? Search all error messages →

Maintenance & backups

Spot-check after every Authentik update. An update can change flows and providers; the integrations themselves are robust, but the quick test is worth it: curl on the protected path must still return 302, and the discovery document must show your endpoints. Two commands that keep a login from being silently broken for weeks.

Client secrets belong in the password manager. They live in the application (environment variables) and in Authentik. If one is regenerated, the other side has to follow – note down which app uses which secret when you create it, otherwise you will be searching in both systems later.

The database matters even more now. Providers, applications and group bindings all live in PostgreSQL. The pg_dump from part 1 is no longer a nice-to-have: without it you rebuild every integration after an outage. Back it up daily with Restic.

Keep the emergency exit open. Every service behind forward auth is only as reachable as Authentik. Keep a local account in important applications – and put no tools behind SSO that you need for repairs; that applies especially to Dockge and similar management interfaces. Monitoring on /-/health/ready/ notices the outage before your users do.

Tidy up tokens. The bootstrap token from step 1 is an admin access without a second factor. Once the setup is in place, delete it under Directory → Tokens – and create a dedicated token with limited rights for later automation.

You might also like