Skip to main content
intermediatePart 4

The binding that wasn't there: group access control, and what SSO doesn't protect

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

Part 3 put authentik in front of Langfuse and ended with an admission: the bindings step was left empty, so every account in authentik could reach Langfuse and nothing would warn you.

This part closes that gap on the LiteLLM gateway — and the closing went wrong in a way worth more than the procedure. We configured the bindings in authentik's wizard, watched them appear in the wizard's own table, submitted, and ended up with zero bindings saved. The application was open to everyone, the UI said nothing, and the only reason we found out is that we tested with an account that should have been refused and wasn't.

Then, once access control genuinely worked, we called the gateway's API from a shell with no account, no session and no group. It answered normally. That one isn't a bug — it's the difference between a control plane and a data plane, and assuming SSO covers both is how people conclude they've secured something they haven't.

Reproducibility

One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. authentik 2026.8.1, the same instance from Part 3 on host port 9100. LiteLLM main-stable from Part 1 of the gateway series, bound to 127.0.0.1:4000, models served over WEC Inference.

Plain HTTP on a private LAN, as in Part 3.

What's new here​

Part 3 taught the wizard — application, provider, redirect URI, scopes. That isn't repeated; it's all in Part 3 and the steps are identical for any app. Three things are new:

A group, as the unit you grant access to, so that adding somebody to a team and giving them access are the same action.

A binding, which attaches that group to an application, so reaching it requires membership rather than merely existing.

A boundary, demonstrated rather than asserted: what SSO protects, and what it doesn't touch at all.

Prerequisites​

  • A working authentik, ideally the one from Part 3
  • A running LiteLLM gateway, from Part 1 of the gateway series
  • Its LITELLM_MASTER_KEY, which you'll need to create a virtual key
  • SSH access to the box, because the gateway is bound to 127.0.0.1

Two doors, one of them guarded​

Two boxes, no arrow between them — that's the point. Everything this post configures lives in the left one. The right one never consults authentik at all.

There is also a third way in that the diagram deliberately leaves out, because it belongs to neither plane: LiteLLM's master key logs into the Admin UI directly, skipping the group check entirely. We hit it in Step 5.

Step 1 — A group, and two accounts​

Directory → Groups → New Group, name it gateway-admins, and leave Superuser privileges off. That flag makes members administrators of authentik itself and has nothing to do with which applications they can reach.

authentik ships three groups already — authentik Admins, authentik Agent-Users, authentik Read-only — and authentik Admins already contains your akadmin. Binding that one instead would work today and quietly mean "anyone I ever make an authentik admin," which is not the same intent.

authentik's Groups list showing only the three built-in groups, with authentik Admins already holding one member

Add akadmin to gateway-admins. Then Directory → Users → New User, type Internal, username contractor, and no groups at all.

Internal rather than External so that the only difference between the two accounts is membership — any other difference muddies the result. Not a Service Account: those are machine-to-machine and don't do interactive logins.

authentik creates users without a password, and the Edit dialog has no password field — it's a separate action on the user's detail page. From the shell:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import User
u = User.objects.get(username='contractor')
u.set_password('Contractor123!')
u.save()
print('password set for', u.username)
" 2>/dev/null | tail -2
Silencing ak shell

Every ak shell call prints roughly sixty lines of bootstrap JSON before your output. 2>/dev/null | tail -N drops the noise and keeps the last N lines. Used throughout below.

Step 2 — Register the gateway, and watch the slug​

Run the Part 3 wizard: application LiteLLM, provider OAuth2/OpenID, authorization flow default-provider-authorization-explicit-consent, client type Confidential.

The gateway is published on 127.0.0.1:4000, so your browser can't reach it — and an OIDC login happens in a browser. Open a tunnel from your workstation:

ssh -L 4000:127.0.0.1:4000 ubuntu@10.80.4.212
ssh -L warns, then carries on without the tunnel

If something already holds local port 4000, ssh prints bind: Address already in use and still gives you a working shell — with a dead forward. The only symptom is ERR_CONNECTION_REFUSED in the browser, which reads like the gateway being down. Check the port before blaming the gateway: lsof -nP -iTCP:4000 -sTCP:LISTEN.

Everything the browser types is now localhost:4000, so that's the redirect URI, mode Strict:

http://localhost:4000/sso/callback

Now the first surprise. Ask authentik what it actually named things:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
for a in Application.objects.all():
print(repr(a.name), '->', repr(a.slug))
" 2>/dev/null | tail -5
'Langfuse' -> 'langfuse'
'LiteLLM' -> 'lite-llm'

lite-llm, not litellm. The slugifier read the capitals in "LiteLLM" and split them. Part 3 called the slug load-bearing because it lands in the issuer URL, and here it is: http://10.80.4.212:9100/application/o/lite-llm/. Set the slug explicitly rather than letting a CamelCase name generate one, or carry that hyphen in every URL forever.

Read the credentials back through the real slug:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='lite-llm').get_provider()
print('CLIENT_ID :', p.client_id)
print('CLIENT_SECRET:', p.client_secret)
" 2>/dev/null | tail -3

Step 3 — The bindings the wizard threw away​

The wizard's step 4 is Configure Bindings, and Part 3 walked past it showing No bound policies. This time we didn't walk past it. We bound the group there, the wizard listed it in its table, and we submitted.

The wizard's Configure Bindings step, empty, reading No bound policies

The same wizard step after binding, listing Group gateway-admins as enabled

Then we asked the database what existed:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
app = Application.objects.get(slug='lite-llm')
bs = list(app.bindings.all())
print('binding count:', len(bs))
for b in bs:
print('group', b.group, '| enabled', b.enabled, '| negate', b.negate)
" 2>/dev/null | tail -4
binding count: 0

Zero. The application's own bindings tab agreed — No Policies bound. Two independent sources, one conclusion: the wizard displayed access control that was never written.

The LiteLLM application's own bindings tab reading No Policies bound

This is worse than skipping the step

Part 3's lesson was "don't skip bindings." The real hazard is that you can complete them, see your rules in the wizard's table, submit, and end up with an application that admits everyone — no error, no warning, and a UI that looked correct the whole time.

An application with zero bindings isn't broken in any way authentik reports. It's doing exactly what it should: nothing bound means nobody is excluded. Verify bindings from the database or the application's own bindings tab — never from the wizard.

Bind it from the application instead: Applications → LiteLLM → Policy / Group / User Bindings → Create or bind…, then Bind a group under Bind Existing… → gateway-admins → Create.

Pick Bind a group specifically. The Choose Policy Type section below it creates brand-new policies (Event Matcher, Expression, GeoIP); a group binding is already a rule and needs none of them.

The Create Binding dialog with gateway-admins selected, Enabled on, Negate Result off and Failure Result set to Don't Pass

Then verify, because the table is what lied:

binding count: 1
group Group gateway-admins | enabled True | negate False
Skill unlocked 🏅

You can restrict an application to a group instead of leaving it open to every account — and, more importantly, verify the binding actually persisted (app.bindings.all()) rather than trusting the wizard's own table.

That page states the mode plainly: "The currently selected policy engine mode is ANY: Any policy must match to grant access." With one binding, ANY and ALL behave identically. With two they don't — ANY means one passing rule is enough, so a stray User Contractor binding sitting beside the group would grant precisely the access this post is trying to deny.

Step 4 — Point the gateway at authentik​

LiteLLM reads generic OIDC settings from GENERIC_*. The endpoints come from the discovery document:

cat >> ~/llm-gateway/.env <<'EOF'
GENERIC_CLIENT_ID=<client id from Step 2>
GENERIC_CLIENT_SECRET=<client secret from Step 2>
GENERIC_AUTHORIZATION_ENDPOINT=http://10.80.4.212:9100/application/o/authorize/
GENERIC_TOKEN_ENDPOINT=http://10.80.4.212:9100/application/o/token/
GENERIC_USERINFO_ENDPOINT=http://10.80.4.212:9100/application/o/userinfo/
PROXY_BASE_URL=http://localhost:4000
EOF

PROXY_BASE_URL is the one that bites. LiteLLM builds its redirect URI from it, so it has to equal both what the browser types and what authentik has registered. Ours disagreed on the first attempt and produced a Redirect URI Error — the redirect_uri in the address bar is what LiteLLM sent, the Strict value on the provider is what authentik expected, and a port, a hostname or a trailing slash between them is enough to fail.

The gateway's compose file uses env_file: .env, so unlike Langfuse in Part 3 there's no override file to write:

cd ~/llm-gateway && docker compose up -d && docker compose exec litellm env | grep -c GENERIC_

Five. Before the edit it was zero — the same class of check Part 3 used to catch variables that never reached the process.

Step 5 — Both sides of the binding​

Open http://localhost:4000/ui through the tunnel. The login page carries a finding this post didn't go looking for.

Login with SSO appears, as intended. Above it sits a username and password form, and LiteLLM's own panel explains it: "By default, Username is admin and Password is your set LiteLLM Proxy MASTER_KEY."

The LiteLLM login page showing a username and password form above the Login with SSO button, with a panel naming the master key as the default password

The master key is a complete bypass. Anyone holding it reaches the admin UI without touching authentik, without a group, without a binding. Everything configured in Steps 1–3 sits beside a door that ignores all of it — and that door's key is also the gateway's root credential, able to mint API keys and reach every model.

AUTO_REDIRECT_UI_LOGIN_TO_SSO=true sends /ui straight to authentik instead of showing the form. It stops advertising the password path; it doesn't remove it. Treat the master key as a break-glass credential and don't mistake the redirect for a fix.

Sign in as akadmin, who is in gateway-admins. authentik shows the consent screen from Part 3's explicit-consent flow, and you land in the dashboard at /ui/?login=success.

authentik&#39;s login screen reading Log in to continue to LiteLLM

The consent screen for akadmin, listing Email address and General Profile Information

Now contractor.

All incognito windows share one session

Opening "a new private window" while another is already open reuses the same cookies, so authentik signs you back in as the previous user. The symptom is an instant login that looks like your access control failed. Close every incognito window, or use a different browser.

Permission denied. Request has been denied.

authentik refusing contractor with Permission denied and Request has been denied

Note where that happened: authentik refused before the consent screen. The policy check runs ahead of consent, so a blocked user never sees the permissions page — which is also how we caught the missing bindings earlier. When contractor reached a consent screen, that was the tell.

The part that is not closed​

From a shell on the box — no authentik account, no session, no group, nothing but a key:

curl -s http://127.0.0.1:4000/v1/chat/completions -H "Authorization: Bearer sk-..." -H "Content-Type: application/json" -d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}' | jq '{model, answer: .choices[0].message.content}'
{
"model": "qwen-mid",
"answer": "\n\nOK"
}

The curl call answering normally from a shell with no authentik session

Nothing is broken. SSO never applied to that endpoint. Steps 1–5 protected /ui — the control plane, where humans create keys and read spend. The data plane at /v1/* authenticates machine callers with API keys, and it has to: an agent running at 3am can't complete a browser login with a consent screen.

That key was itself created from a shell, with the master key, while no SSO session existed anywhere:

cd ~/llm-gateway && source .env && curl -s http://127.0.0.1:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" -d '{"key_alias":"sso-demo","models":[]}' | jq -r '.key'

The consequences, stated plainly, because "we put SSO on the gateway" gets said in meetings as though it settled the question:

  • Removing someone from gateway-admins does not revoke their keys. They lose the UI and keep every key they made. Offboarding is two actions; only one is the one people remember.
  • A leaked key is unaffected by identity entirely. No session to expire, no group to remove it from.
  • Keys outlive people, unless you gave them an owner and an expiry when you made them.

The gateway's own key model — scoped keys, budgets, expiry, per-key models — is what covers that ground, and it's a different mechanism from the one configured here. Complementary, not substitutable.

One more layer: getting in is not being able to do anything​

Signed in as akadmin through SSO, the Virtual Keys page had no Create Key button.

LiteLLM assigns SSO users a default role that isn't admin. Passing authentik's binding got us into the UI; what we could do there is LiteLLM's own role model, which we never configured. Authentication, then authorization, then application-level roles — three distinct layers, and clearing one says nothing about the next.

Troubleshooting — the errors this run actually produced​

Application matching query does not exist​

The ak shell lookup fails even though the application is visibly there in the UI. The slug isn't what you assumed — authentik derives it from the name, and a CamelCase name like LiteLLM becomes lite-llm. Ask rather than guess:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
for a in Application.objects.all():
print(repr(a.name), '->', repr(a.slug))
" 2>/dev/null | tail -5

Redirect URI Error​

authentik refuses before any login form. The redirect_uri in the address bar is what LiteLLM sent; the Strict value on the provider is what authentik expects. Ours disagreed because PROXY_BASE_URL said one host and the registered URI said another. Print what's registered and compare character by character:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='lite-llm').get_provider()
for r in p.redirect_uris:
print(repr(r.matching_mode), repr(r.url))
" 2>/dev/null | tail -5

If you edit it and the error persists, Applications → Clear cache — authentik caches provider config.

ERR_CONNECTION_REFUSED on localhost:4000​

The tunnel isn't forwarding, even though ssh connected. If something already held the local port, ssh printed bind: Address already in use and carried on with a working shell and a dead forward. Check the port, then restart the tunnel:

lsof -nP -iTCP:4000 -sTCP:LISTEN

The user I expected to be refused got in​

Two causes, both real here. Either a stray user binding grants them directly — policy engine mode ANY means one passing rule is enough — or the bindings were never saved. Ask the database, not the UI:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
app = Application.objects.get(slug='lite-llm')
bs = list(app.bindings.all())
print('binding count:', len(bs))
for b in bs:
print('group', b.group, '| user', b.user, '| enabled', b.enabled, '| negate', b.negate)
" 2>/dev/null | tail -5

A user who reaches the consent screen has already passed the policy check — that's the tell. A blocked user never gets that far.

The refused user logs straight in without a prompt​

Browser session, not policy. All Chrome incognito windows share one session, so opening "a new private window" while another is open keeps the previous user signed in. Close every incognito window, or use a different browser.

No Create Key button in the admin UI​

You're signed in through SSO, and LiteLLM gives SSO users a default role that isn't admin. Create keys from the shell with the master key, or configure LiteLLM's own roles — a separate mechanism from anything authentik does.

Skill unlocked 🏅

You can tell a control plane from a data plane on your own stack: put SSO and group membership in front of an admin UI, then prove the API behind it still answers on a key alone — and name what that means for offboarding, leaked keys and keys that outlive people.

What this did and didn't buy you​

Done: a group that means something, a binding that enforces it and that we verified in the database rather than trusting a table, and an admin UI where access follows membership.

Not done:

  • The API is untouched, by design, as demonstrated.
  • The master key still logs in, bypassing all of it.
  • SSO users have no LiteLLM role beyond the default.
  • Still plain HTTP. Every token and secret here crosses the network in the clear.
  • No MFA.
Finished this tutorial?
Mark it complete to earn Membership, not just an account on your skill path.

What's next​

TLS and real exposure. The gateway is on 127.0.0.1:4000 and reachable in this post only through an SSH tunnel; authentik is plain HTTP. Making either properly reachable means a reverse proxy, a hostname and a certificate — and every redirect URI, PROXY_BASE_URL and issuer changes the day that happens.

Further reading​

Comments & questions

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