A self-hosted classroom with SSO-only login and token-locked Jitsi
Blog · sso

A self-hosted classroom with SSO-only login and token-locked Jitsi

One identity from forward-auth headers, roles from groups, per-room Jitsi tokens, and a 'probe' that was only a page load

In July a single cloud address made 53 requests to my self-hosted Jitsi server in 51 seconds, across 42 different paths, and every one of them succeeded. On a dashboard, that looks exactly like someone enumerating meeting rooms. It was a browser loading the welcome page. Nobody reached a room.

That episode is a good entry point into the system behind it: a small self-hosted classroom, where teachers run courses over Jitsi Meet, students join with one account, and nobody can create or enter a room without a token issued for that room. In October 2026 I rebuilt it from scratch, because version 1 had two login systems, a public API key literally set to changeme, and its video secrets committed to Git. This post describes the design of version 2 and the lessons from running it.

Contents · 10 sections

TL;DR #

  • Use the single sign-on gateway as the only identity. If the app keeps its own passwords too, you have two systems to secure and keep in sync.
  • Derive roles from SSO groups on every request. Removing someone from a group removes their access immediately.
  • Trust identity headers only if only the proxy can reach the app, and strip them on public routes.
  • Lock Jitsi with per-room tokens and turn off anonymous access.
  • If mobile clients connect and then drop, try forcing media through TURN.
  • On a single-page app, many distinct paths with 100% success is asset loading, not enumeration. Count the non-asset paths.

What version 1 got wrong #

Version 1 worked. It also had every classic weakness of an app that grew one feature at a time:

  • an SSO login in front (Authelia), and a second login inside the app, with its own password database;
  • an admin API protected by a key whose value was changeme;
  • the Jitsi signing secret and the TURN server secret in the Helm values file, in Git.

Every one of those is a second copy of an identity or a secret. Version 2 removes the copies.

Identity: one source, read on every request #

Traefik sits in front of everything. For protected routes, its forward-auth middleware asks Authelia whether the request is logged in. If it is, Traefik passes the request on with headers that describe the user: Remote-User, Remote-Name, Remote-Email and Remote-Groups. Traefik overwrites anything a client tries to send in those headers.

The classroom app has no login page and no password table. It reads those headers on every request and maps groups to roles:

ADMIN_GROUPS   = {"admins", "classroom-admins"}
TEACHER_GROUPS = {"classroom", "classroom-teachers"}
STUDENT_GROUPS = {"classroom-students"}

def role_for(groups):
    g = set(groups)
    if g & ADMIN_GROUPS:   return "admin"
    if g & TEACHER_GROUPS: return "teacher"
    if g & STUDENT_GROUPS or any(x.startswith("classroom") for x in g):
        return "student"
    return "guest"

There is no second user database to keep in sync. When an admin removes someone from a group, the next request already has the new role. The app keeps a small profile cache (display names for member lists), refreshed at most every 5 minutes per user, but it never decides access from it.

When can you trust identity headers? #

Only when nothing but the proxy can talk to the app. Two controls make that true here.

A NetworkPolicy allows traffic to the classroom pod only from Traefik. Another pod in the cluster cannot connect and send its own Remote-User: admin.

Public routes strip the headers. Some pages must work without a login: registration with an enrolment code, email confirmation, guest invitation links, one-time welcome links, static files and the health check. Those routes bypass Authelia, so a client could send forged identity headers there. Traefik blanks them on those routes, and the app refuses to read identity on its public blueprint anyway:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: strip-identity-headers
spec:
  headers:
    customRequestHeaders:
      Remote-User: ""
      Remote-Groups: ""
      Remote-Name: ""
      Remote-Email: ""

Belt and braces: either control alone would stop the attack; both together survive one of them being misconfigured.

Accounts: through a scoped API, never by editing files #

People still need SSO accounts. A teacher approves a registration on the room page, and the app creates the account. It does that through a small service API on my Authelia account manager, with a bearer token whose scope is hard-coded: it can only manage groups whose names start with classroom and grant a short allowlist of services. The classroom cannot make anyone an admin of anything else, and nothing ever edits Authelia’s configuration files by hand.

For people whose email address may be wrong, approval produces a one-time welcome link the teacher can hand over directly (copy, share sheet, QR code, or a burn-after-reading paste) instead of an email that never arrives.

The rewrite came with 22 tests that run against a real PostgreSQL started from the version 1 schema with sample data, so the migration path is tested, not just the new code.

Jitsi: a token per room, and no anonymous access #

Jitsi Meet supports JWT authentication: the XMPP server (Prosody) verifies a signed token before letting a participant join. The classroom issues one token per person per room:

payload = {
    "aud": "jitsi", "iss": APP_ID, "sub": XMPP_DOMAIN,
    "room": room_id,                                  # valid for this room only
    "context": {"user": {"id": user_id, "name": name,
                         "affiliation": "owner" if moderator else "member"}},
    "nbf": now - 10, "exp": now + TTL,
}

Teachers, assistants and admins get the moderator affiliation; students and guests are members. Anonymous access is off (ENABLE_GUESTS=false), and Jitsi’s token-auth URL points to the classroom’s join page, so anyone who opens a meeting link directly is sent through the classroom first.

An earlier lesson is built in too. In April a “ghost” room appeared that nobody had scheduled. Since then a guest-restriction module has prevented guests from creating rooms, while still letting invited guests join.

The secrets moved out of Git into a Kubernetes Secret, injected into all four Jitsi components (web, Jicofo, the video bridge and Prosody). Both were rotated while no meeting was running. The check was simple and decisive: the old token was rejected, a new one joined.

Mobile: connected, then silence #

After the rebuild, browser meetings worked, but the Jitsi Meet mobile app joined and then stayed disconnected. Media from phones failed on the direct ICE candidates the bridge offered. The fix was to force all media through the TURN server:

// custom-config.js for the Jitsi web component
config.p2p = config.p2p || {};
config.p2p.enabled = false;               // all media through the video bridge
config.p2p.iceTransportPolicy = 'relay';  // quoted! see below

The comment in my values file records one more trap: written without quotes, relay is a JavaScript variable name, the config script throws a ReferenceError, and the mobile app never connected at all. A config file that is code fails like code.

Relaying everything costs some bandwidth on the TURN server, which is irrelevant at classroom scale, and it makes the media path the same for every client.

Two operational traps #

Interface names. The video bridge binds its UDP port only on allowed interfaces, configured by name. After a graphics card was reseated in September, the network card got a different PCI address and a new name, the bridge found no allowed address, and it crash-looped. The setting now lists both names.

A failed Helm release. Some of these fixes were applied to the live ConfigMap and Deployment because the Jitsi Helm release had been in a failed state since an earlier revision. The next helm upgrade would revert them. Until the release is reconciled, the patches are documented next to the values file and re-applied after any upgrade. If you patch around Helm, write down what you patched.

The probe that was not a probe #

Back to July. One cloud address, 53 requests, 42 distinct paths, all 2xx, 0 errors. The pattern looked like successful enumeration. The paths told a different story: every one of the 42 was a static asset (sounds, CSS, the Jitsi JavaScript library, images), and the audio files came back as 206 Partial Content, which is a browser preloading media. The only non-asset path in all 53 requests was /.

Jitsi’s welcome page also loads app-store badges that a participant inside a room never requests. A real join always includes a request for the room’s own path. So one query answers the question:

SELECT path FROM requests
WHERE client_ip = '<ip>'
  AND path !~ '^/(sounds|images|css|libs|fonts|static|lang)/'
  AND path !~* 'favicon|worker';
-- only "/" returned: nobody reached a room

The general rule: enumeration produces 404s. On a single-page app, many distinct paths with 100% success is a page loading its assets. Count the paths that are not assets.

Checklist #

  • One identity system: SSO headers, no local passwords.
  • Roles from groups, evaluated on every request.
  • NetworkPolicy so only the proxy reaches the app.
  • Strip identity headers on every route that bypasses SSO, and ignore them in code.
  • Account changes through a scoped API, never by editing the SSO config.
  • Per-room Jitsi tokens, guests disabled, guest room creation blocked.
  • Video secrets in a Secret, rotated after any exposure, with an old-token-rejected test.
  • TURN relay for unreliable mobile media.
  • Document any patch applied outside Helm.

Frequently asked questions #

How do I secure a self-hosted Jitsi Meet server? #

Enable JWT authentication, disable anonymous guests, issue tokens per room from your own app, and block guests from creating rooms. Keep the JWT secret out of Git and rotate it if it was ever committed.

Is it safe to trust Remote-User headers from a reverse proxy? #

Only if the app can be reached exclusively through that proxy, the proxy overwrites the headers on authenticated routes, and public routes strip them. A NetworkPolicy or firewall rule enforces the first condition.

Why does the Jitsi mobile app connect and then drop? #

Commonly because media cannot flow over the direct candidates from the phone’s network. Forcing the relay transport through a TURN server makes media take a path that works from most networks.

How can I tell enumeration from normal traffic on a web app? #

Look at the non-asset paths and at error codes. Enumeration guesses, so it produces many 404s. A page load produces many distinct asset paths, almost all successful.

The lesson #

Version 1 failed in the places where it kept a second copy of something: a second login, a second key, a second home for a secret. Version 2 has one identity, one source of roles and one way to change accounts, and the app trusts that identity only where the network guarantees it. The same habit, looking at what each request actually is, turned a scary-looking dashboard into a page load.