Skip to main content
intermediatePart 3

One login for everything: putting authentik in front of a self-hosted app

· 20 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
authentik+

Part 1 closed the ports nobody meant to open. Part 2 stopped two containers from running as root. Both were about the machine. Neither touched the question a self-hosted AI stack answers worst: who is allowed to log in, and where is that decided?

Same box as before. The Langfuse we've been hardening since Part 1 — the one whose Postgres, ClickHouse and Redis Part 1 found correctly bound to localhost, and whose worker Part 2 dropped off root — was deployed back in Catch what your tests miss. Two parts have now secured the machine underneath it without once touching the application's own front door. This is the first part that changes Langfuse itself.

Right now that decision is made in each app, separately. Langfuse has its own email-and- password table. So does every other tool on the box. Each one is a place where an account can outlive the person who owned it, where a password can be reused, and where "remove this person's access" means remembering that the app exists at all.

This post moves that decision to one place. That place is authentik — an open-source identity provider you run yourself, the self-hosted counterpart to Okta or Auth0. It holds the accounts, runs the login screen, and vouches for who someone is to any app that asks. Apps stop storing passwords and start asking authentik.

It's the same job Keycloak does, and Keycloak is the better-known name. authentik earns the pick here on setup cost: a compose file and a wizard against Keycloak's realms, clients and JVM tuning. For one box and one app, that difference is the whole decision.

We deploy it, connect Langfuse to it over OIDC, and end with a login that goes through authentik and back.

Reproducibility

One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. authentik 2026.8.1 from ghcr.io/goauthentik/server, Postgres 16. Langfuse v3 (langfuse/langfuse:3) — the deployment carried over from Parts 1 and 2, reachable at 10.80.4.212:3001. authentik published on host port 9100.

Addresses in this post are a private LAN. Substitute yours. Everything here is plain HTTP on a trusted network, which is fine for a first run and not fine for anything reachable from outside — see What's next.

The vocabulary, before the clicking​

Every OIDC guide assumes you already know these five words. Getting them straight up front is the difference between configuring this once and guessing at fields for an hour.

OP and RP. The OpenID Provider is the thing that knows who people are — authentik. The Relying Party is the app that wants to be told — Langfuse. The RP never sees a password. It receives a signed statement from the OP saying "this is admin@example.com, I checked."

Provider vs application. authentik splits what most tools merge. A provider is the protocol endpoint — the OIDC machinery, the client ID and secret, the redirect URI. An application is the thing users see and the thing access rules attach to. They pair one-to-one: one application, one provider, and the wizard in Step 3 creates both together.

The redirect URI is where authentik is allowed to send the user back after a successful login. It's matched exactly and it's the single most common source of failure. Not a prefix, not a hostname — the full URL, character for character.

Scopes vs grant types. A scope is what information the app asks for (openid email profile). A grant type is the mechanism by which it asks (authorization_code). Different questions; the UI puts them near each other and it's easy to conflate them.

What the login actually does​

The part worth noticing: the code travels through the browser, the secret never does. That exchange in the second-to-last step happens between the two containers directly, which is why the client secret is a server-side setting and why Langfuse must be able to reach authentik over the network — not just your browser.

Prerequisites​

  • A running Langfuse, ideally the one from Part 1
  • Docker with the Compose plugin
  • A free host port for authentik (we use 9100)
  • The LAN IP of the box, not localhost — two containers need to reach each other and your browser needs to reach both

Step 1 — Deploy authentik​

authentik ships a compose file and generates its own secrets. Create a directory and pull both down:

mkdir -p ~/authentik && cd ~/authentik
curl -O https://goauthentik.io/docker-compose.yml

Now the environment file. authentik needs a Postgres password and a secret key, both random, plus the ports it will publish:

cd ~/authentik
{
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')"
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')"
echo "COMPOSE_PORT_HTTP=9100"
echo "COMPOSE_PORT_HTTPS=9543"
} > .env
chmod 600 .env

AUTHENTIK_SECRET_KEY signs sessions and tokens. If you lose it, every existing session and every issued token becomes invalid — so it belongs in your password manager, not only in this file.

Bring it up. First boot runs database migrations and takes a minute or two:

cd ~/authentik && docker compose up -d
cd ~/authentik && docker compose ps

Three containers — server, worker and postgresql — all reporting (healthy). Older authentik compose files also shipped a separate redis service; 2026.8.1 no longer does, so three is correct and nothing is missing. server is the one that has to be healthy before you continue.

docker compose ps showing authentik's server, worker and postgresql containers all reporting healthy

Step 2 — Claim the admin account​

authentik's first boot leaves the admin account unclaimed. The setup URL exists exactly once; visiting it is how you take ownership.

Open http://10.80.4.212:9100/if/flow/initial-setup/, and set the email and password for the default admin (akadmin).

The authentik initial-setup screen, setting the email and password for the default akadmin account

Use an address you control. This account can do everything, so give it a real password and store it somewhere durable.

This URL is a one-time key

Until someone completes this form, anyone who can reach the port can become the administrator of your identity provider. Do it immediately after the containers come up — not tomorrow.

You land on the Application Dashboard — the user-facing view, not the admin one, and empty because nothing is configured yet.

The authentik Application Dashboard reading No Applications available, with a Create a new application button

Create a new application here goes to the same place as the admin interface does; the Admin interface button top-right is how you reach everything else.

Step 3 — Run the application wizard​

Applications → Applications → Create. authentik walks you through five steps, and the step list on the left is worth reading before you touch anything:

Application → Choose a Provider → Configure Provider → Configure Bindings → Review and Submit

That's the vocabulary from earlier, in order. The wizard creates the application and its provider in one pass and pairs them for you. (Applications → Providers → Create builds a provider on its own, if you ever need one before the app that uses it.)

3.1 — Application​

  • Application Name — Langfuse. The label users see on the dashboard.
  • Slug — langfuse. Load-bearing: it becomes part of the issuer URL, so changing it later changes the URL you have to reconfigure Langfuse with.
  • Group — leave empty. This groups apps on the dashboard; it has nothing to do with user groups or access control, despite the name.
  • Policy engine mode — leave on ANY. It decides how multiple bound policies combine. With no policies bound, it makes no difference yet.

The wizard's Application step, showing the Name, Slug, Group and Policy engine mode fields before anything is entered

3.2 — Choose a Provider​

Eight provider types. Take OAuth2/OpenID Provider — "OAuth2 Provider for generic OAuth and OpenID Connect Applications."

The wizard's provider type step with eight tiles and OAuth2/OpenID Provider selected

Worth knowing what you're not picking, because two of these solve a different problem: Proxy Provider puts authentik in front of an app that has no SSO support of its own, and LDAP Provider exposes authentik to things that only speak LDAP. Langfuse speaks OIDC natively, so it gets a real OIDC provider.

3.3 — Configure Provider​

The step that matters, and the one that will cost you time if you rush it.

  • Authorization flow — default-provider-authorization-explicit-consent. "Explicit" shows the user a consent screen listing what Langfuse asked for. The implicit variant skips it. Pick explicit for a first build: that screen is a live readout of your scope configuration, which makes a misconfiguration visible instead of silent.

  • Client type — Confidential (scrolled above the redirect fields). Langfuse is a server and can keep a secret, so it gets one. Public clients are for browsers and mobile apps that can't.

  • Redirect URIs/Origins (RegEx) — the field with two dropdowns and a value. Set the mode to Strict, the purpose to Authorization, and the URI to Langfuse's NextAuth callback:

    http://10.80.4.212:3001/api/auth/callback/custom

    The trailing custom is not a placeholder — it's the provider ID NextAuth assigns to its generic OIDC provider. Use your own host and port, but that path is fixed.

  • Signing Key — leave authentik Self-signed Certificate. This signs the ID token; Langfuse fetches the matching public key from the jwks_uri in Step 5 and verifies against it. Self-signed is correct here — the RP trusts this key because discovery handed it over, not because a CA vouched for it.

  • Logout URI and token validities — leave alone.

The provider once it's saved — explicit-consent authorization flow, Confidential client type, and Authorization Code as the grant:

The OAuth2/OpenID provider edit form showing the explicit-consent authorization flow, Client Type set to Confidential, and Authorization Code checked

Strict means strict

The mode dropdown offers Regex as well, and authentik's own help text points out you can set it to .* to allow any redirect URI. Don't. An open redirect URI means anyone who can start a login can have the authorization code delivered to a host they control. Strict, with one exact URL, is the whole point of the field.

Further down, Scopes comes pre-populated with four mappings: email, openid, profile and offline_access. Leave them. openid is mandatory and is what marks this as OIDC rather than bare OAuth2. email is the claim Langfuse keys the account on. profile carries the display name. offline_access is the one the wizard adds without asking — it permits a refresh token, so a session can be renewed without sending the user back through the login flow.

3.4 — Configure Bindings​

No bound policies. — and we are leaving it that way.

The wizard's Configure Bindings step reading No bound policies

This is the gap, and it's deliberate

Read the wizard's own description: "These policies control which users can access this application." With nothing bound, the answer is everyone who can authenticate. On a box where you're the only account, that's the same thing as "just me" — which is why it's tolerable here and why the wizard lets you skip it.

It is not access control. Every account you ever create in this authentik gets into Langfuse by default, and nothing will warn you. Groups and policy bindings are how that gets fixed, and they're the subject of the next post — where the thing being protected is a gateway and the answer matters a great deal more.

3.5 — Review and Submit​

Check the slug and the redirect URI once more, then submit.

Step 4 — Read the configuration back out of authentik​

Rather than assembling URLs by hand, ask authentik. Every OIDC provider publishes a discovery document, and authentik's lives under the application slug:

curl -s http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration \
| jq '{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, grant_types_supported}'
{
"issuer": "http://10.80.4.212:9100/application/o/langfuse/",
"authorization_endpoint": "http://10.80.4.212:9100/application/o/authorize/",
"token_endpoint": "http://10.80.4.212:9100/application/o/token/",
"userinfo_endpoint": "http://10.80.4.212:9100/application/o/userinfo/",
"jwks_uri": "http://10.80.4.212:9100/application/o/langfuse/jwks/",
"grant_types_supported": [
"authorization_code",
"refresh_token",
"implicit",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code"
]
}

Two things to take from this. The issuer is the only URL Langfuse needs — it will fetch this same document and discover the rest. And grant_types_supported lists what the protocol offers, not what your provider will accept; we're using authorization_code and the presence of password in that list is not an invitation.

The discovery document returned by the running authentik, showing the issuer, the authorize, token and userinfo endpoints, the JWKS URL and the supported grant types

Now the client credentials. They're in the provider's detail page in the UI, but docker compose exec is faster and copy-pastes cleanly:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='langfuse').get_provider()
print('PROVIDER :', p.name)
print('CLIENT_ID :', p.client_id)
print('CLIENT_SECRET:', p.client_secret)
"

Note that this looks the provider up through the application slug rather than by provider name. The wizard named your provider Provider for Langfuse, not langfuse — the slug is the thing you chose and the thing that stays predictable. get_provider() also matters: reaching for application.provider hands back the base class with client_id empty, which looks alarmingly like a broken provider and isn't.

Keep that output out of your shell history and out of screenshots.

The provider's detail page carries the same values, if you'd rather read them there — client type, client ID, the strict redirect URI, and every discovery URL in one place. The client secret is the one thing it doesn't print:

The Provider for Langfuse overview page listing client type, client ID, the strict redirect URI and the OpenID configuration, authorize, token, userinfo and JWKS URLs

Step 5 — Point Langfuse at it​

Langfuse reads generic OIDC settings from AUTH_CUSTOM_* variables. Add them to ~/langfuse/.env:

AUTH_CUSTOM_NAME=authentik
AUTH_CUSTOM_ISSUER=http://10.80.4.212:9100/application/o/langfuse
AUTH_CUSTOM_CLIENT_ID=<the client id from Step 4>
AUTH_CUSTOM_CLIENT_SECRET=<the client secret from Step 4>
AUTH_CUSTOM_SCOPE=openid email profile

The issuer here has no trailing slash while the discovery document reported one. Both work — Langfuse normalizes it.

Also confirm NEXTAUTH_URL matches the address you actually browse to:

grep NEXTAUTH_URL ~/langfuse/.env
NEXTAUTH_URL=http://10.80.4.212:3001

If this says localhost while your redirect URI says 10.80.4.212, the callback will be built with the wrong hostname and authentik will reject it.

Now the part that will waste your afternoon if you skip it. Writing those variables into .env is not enough. Langfuse's shipped compose file lists the variables it passes into the langfuse-web container explicitly, and AUTH_CUSTOM_* isn't among them — they land in .env, get read by Compose for interpolation, and never reach the process. Langfuse starts fine and shows no SSO button, with nothing in the logs to say why.

Pass them through with an override file, ~/langfuse/docker-compose.override.yml:

services:
langfuse-web:
environment:
AUTH_CUSTOM_NAME: ${AUTH_CUSTOM_NAME}
AUTH_CUSTOM_ISSUER: ${AUTH_CUSTOM_ISSUER}
AUTH_CUSTOM_CLIENT_ID: ${AUTH_CUSTOM_CLIENT_ID}
AUTH_CUSTOM_CLIENT_SECRET: ${AUTH_CUSTOM_CLIENT_SECRET}
AUTH_CUSTOM_SCOPE: ${AUTH_CUSTOM_SCOPE}
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: "true"

Compose merges docker-compose.override.yml automatically, so the upstream file stays untouched and survives upgrades. That last variable is the fix for a failure you'd otherwise hit in the next step — the troubleshooting section explains what it does and what it costs.

Recreate the web container and verify the variables arrived:

cd ~/langfuse && docker compose up -d langfuse-web
cd ~/langfuse && docker compose exec langfuse-web env | grep -c AUTH_CUSTOM

Six. If it's zero, the override isn't being picked up — check you're running compose from ~/langfuse and that the filename is exactly docker-compose.override.yml.

docker compose exec counting six AUTH_CUSTOM variables inside the langfuse-web container

Step 6 — Sign in​

Open http://10.80.4.212:3001/auth/sign-in, in a private window so you're not looking at an existing session. There's a new button below the divider under the password form.

The Langfuse sign-in page with an authentik button below the email and password form

The button is labelled authentik because that is your AUTH_CUSTOM_NAME value, rendered verbatim. Set it to Company SSO and that's what the button says — it's a display string and nothing else depends on it.

Two other things in that screenshot are worth naming rather than glossing over. The email-and-password form is still there, and so is Sign up — SSO has been added, not substituted. And the address bar says Not Secure, correctly: this is plain HTTP.

Click the button and you're handed to authentik, which tells you where you came from:

The authentik login screen reading Log in to continue to Langfuse

Log in, and because we chose the explicit-consent flow in Step 3.3, authentik shows what Langfuse asked for before it hands anything over:

The authentik consent screen, Redirecting to Langfuse, listing Email address and General Profile Information

It reads You're about to sign into Langfuse, identifies you as akadmin, and lists two permissions — Email address and General Profile Information. Those are the email and profile scopes in human-readable form. openid doesn't get a line of its own: it carries no personal data to consent to, it's the flag that makes this OIDC rather than plain OAuth2. So a three-scope configuration produces a two-item consent screen, which is correct and not a sign that something dropped.

Continue, and you land in Langfuse, signed in, as the identity authentik vouched for.

That's the loop closed. Langfuse never saw a password.

Troubleshooting — the errors this run actually produced​

OAuthAccountNotLinked​

The first sign-in attempt bounced back to the Langfuse login page with ?error=OAuthAccountNotLinked in the URL and nothing more.

The cause: an email-and-password Langfuse account already existed with the same address authentik was now presenting. NextAuth's default is to refuse the merge. That default is protective — if an identity provider can assert any email and the app auto-links on email alone, then whoever controls the provider can take over an existing account by claiming its address.

AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: "true" tells Langfuse to link them anyway. It is safe here because you own the only provider and you control who can create accounts in it. It would not be safe pointed at a provider where anyone can self-register an arbitrary email.

The alternative, if you'd rather not enable it: delete the pre-existing local account and let SSO create a fresh one.

redirect_uri mismatch, or authentik refusing to redirect​

Read the failing URL in the address bar and compare it to the provider's redirect URI character by character. In this run the mismatches worth naming were the trailing /api/auth/callback/custom path (any other suffix fails), localhost versus the LAN IP, and a stray trailing slash. Strict matching means strict.

No SSO button, no errors​

That's the compose-environment trap from Step 5. Check the variables reached the container:

cd ~/langfuse && docker compose exec langfuse-web env | grep AUTH_CUSTOM_ISSUER

Empty output means Langfuse is running without the config it never knew it was missing.

Discovery fails from inside the container​

Your browser reaching authentik is not proof that Langfuse can. The token exchange is container-to-container:

The langfuse-web image ships no curl, so use the Node runtime that's already in there — fetch is built in:

cd ~/langfuse && docker compose exec langfuse-web node -e \
"fetch('http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration').then(r=>console.log(r.status)).catch(e=>console.log('FAIL',e.message))"

200 is what you want. FAIL with a connection error means the container can't reach authentik at all, which is the actual thing being tested. This is also why the issuer is a LAN IP rather than localhost — inside the Langfuse container, localhost is the Langfuse container.

The node fetch call run inside langfuse-web printing 200

Skill unlocked 🏅

You can run your own identity provider and put an app behind it over OIDC — reading the issuer, client ID and secret out of authentik rather than assembling URLs by hand, and recognising the two failures that actually stop you: a compose file that never passes the variables through, and OAuthAccountNotLinked.

What this did and didn't buy you​

Done: one identity provider, one place where accounts live, one place to revoke them. Langfuse no longer stores a password for you. And you now have a provider you can point the next app at in about five minutes.

Not done, and worth being clear about:

  • No access control. Empty bindings mean every authentik account reaches Langfuse.
  • Plain HTTP. The ID token and the client secret cross the network unencrypted. On a trusted LAN that's a tolerable starting point; exposed to anything else it isn't.
  • One admin, no recovery path. If you lose akadmin, you lose the provider that fronts everything pointed at it.
  • The old login still works. Password sign-in wasn't disabled, so SSO is currently an additional door, not a replacement one.
Finished this tutorial?
Mark it complete to earn Identity in front of every port on your skill path.

What's next​

Part 4 puts identity in front of something where these gaps stop being tolerable: the LiteLLM gateway from the gateway series. Three things this post deliberately left alone:

  • The control plane is not the data plane. SSO protects an admin UI. It does not protect an API — machine callers still authenticate with keys. For a gateway that distinction is the whole game, and assuming otherwise is how people conclude they've secured something they haven't.
  • Groups and policy bindings, so that reaching the application requires membership rather than merely having an account.
  • TLS and real exposure — the gateway is bound to 127.0.0.1:4000, and getting it properly reachable means a reverse proxy, a hostname and a certificate.

The provider-and-application steps will be a short recap with a link back here. The rest is new.

Further reading​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.