Emby had no single sign-on, so I built the plugin

I’ll do my best in my homelab to let a user sign in the same way. When you open a service, it sends you to Authentik, you log in once, and you’re in. One password, one place to turn access off, one place to enforce two-factor. It’s part of the setup I described in The laptop is just a screen.

Except Emby. Emby is my media server, and it only knows its own usernames and passwords. There’s no OpenID Connect support, and no easy plugin for it either, or at least, i couldn’t find it. For a long time it was the one service where my family had a separate password what was getting annoying.

So I wrote the plugin for the feature that i think Emby was missing. It’s called Emby SSO, it runs on my server today, and this post is about what it does, how it works, and the things I got wrong along the way.

What single sign-on means here

Authentik is an open source identity provider. It holds the accounts, does the login page, and enforces things like two-factor and passkeys. OpenID Connect is a standard other applications use to ask Authentik “who is this person?” without ever seeing their password.

Most self-hosted services speak OpenID Connect out of the box in my homelab, thats one of the hard requirements i have when looking for something new to host. Emby didn’t, so i used Jellyfin for a long time, but last weekend that was about to change.

What the plugin does

Browser sign-in. A user opens one bookmarkable link on the Emby server. The plugin sends them to Authentik, Authentik logs them in with whatever policies you have, including MFA, and sends them back. The plugin checks the answer, matches it to an Emby account, and drops the user straight on the Emby home screen. Emby never sees a password.

Sign-in on a television. TV apps only have a username and password box, and nobody wants to type a long token with a remote control. So there’s a PIN flow: the user signs in through Authentik on their phone, gets an eight-character PIN, and types their username plus that PIN into the TV app. The PIN lasts five minutes and works once. It works with the unmodified Emby apps, because a PIN fits in the one field they already have.

Group gating. Only members that are part of a group thats configured in the plugin configuration may sign in. Everyone else is refused with a message. Losing the group in Authentik loses Emby access at the next sign-in.

Automatic accounts. Optional and off by default. A group member who has never used Emby gets an account cloned from a template user whose libraries you chose.

You can’t lock yourself out. Your own Emby administrator accounts keep using Emby’s own password check. If Authentik is down, or the plugin is misconfigured, the dashboard still opens for a Emby Administrator.

How it works under the hood

An Emby plugin is one DLL. This one is C# on .NET Standard 2.0, built against Emby’s plugin SDK, and merged into a single file so there’s nothing else to install next to it.

The interesting part is that it has two personalities.

To Emby, it’s an authentication provider. Emby has an interface for “check this username and password”. The plugin implements it. That’s how the native apps and the PIN flow work: the app sends a username and a password, Emby hands them to the plugin, and the plugin decides. For the PIN flow, the “password” is the PIN it handed out a moment ago.

To the browser, it’s a small web app. The plugin registers two routes on the Emby server:

https://emby.koper.dev/sso/start      sends the user to Authentik
https://emby.koper.dev/sso/callback   receives the answer

The flow between them is the standard OpenID Connect authorization code flow with PKCE, the same one your bank app uses. The plugin validates the ID token’s signature and claims, reads the username and the groups, and only then asks Emby to create a session.

Every sign-in, on every path, runs through the same checks: is the token valid, does the username match an account assigned to this plugin, is the required group present. There’s a brute-force throttle on both the sign-in and the PIN path, and an account is bound to the identity that first used it, so a renamed account in Authentik can’t quietly take over someone else’s library.

How it was built

I didn’t write this alone. I built it with Claude Code as a pair programmer, in the same workspace I described in The laptop is just a screen. The way of working was the same for every feature: write down what it should do, write the tests, make them pass, then try it against a real Emby server. The plugin has 427 automated tests, and the licence service another 441.

That workspace had no browser. Everything about the browser flow was verified with curl against a live Emby 4.9.5.0 server, and the one thing that could not be checked that way, the final hand-off into the web client’s local storage, is marked as unverified in the documentation until someone sees it work. Being honest about what was measured and what was reasoned turned out to be the most useful habit of the whole project.

What went wrong along the way

Building it was mostly the OpenID part. Getting it right was mostly Emby. In the order we hit them:

The configuration page didn’t render. The first version of the settings page loaded, but Emby’s dashboard showed nothing. Emby expects plugin pages to follow its own scroller and data-controller pattern, which isn’t documented anywhere obvious. Once the page was rebuilt that way it worked, and that was the first thing I confirmed by hand on the real server.

A sign-in could be finished by a different browser. Early on, the pending login was only tied to a random state value. That’s the standard defence, but it means whoever holds the callback URL can complete it. Now a pending login is also bound to the browser that started it, which had a side effect: behind a reverse proxy the cookie needs the right settings or the sign-in fails at the last step. That went into the docs.

Emby remembers a user’s provider forever. The first successful sign-in writes the provider onto the user, and Emby never tries another one for that user again. Worse, an account that was never assigned to any provider is offered to every provider in turn, which would have made every existing Emby account reachable through Authentik the moment the plugin was installed. So the plugin refuses any account that hasn’t been deliberately assigned to it. Adopting an account into SSO has to be a decision, not an accident.

Automatic accounts inherited too much. Cloning a template user for new group members sounded simple. The first version copied the template’s profile PIN onto every new account, and could clone a disabled template into a disabled user. Both fixed. The throttle that stops someone creating accounts by guessing usernames also had to be reworked, because the first version could be used to lock everyone out.

Authentik won’t take a real password on the direct grant. For native apps there’s an older OpenID flow where the app sends the password straight to the provider. Authentik accepts it, but only with an app password token the user creates in their own settings. The documentation was wrong about this for a day, and I corrected it. It’s also why the PIN flow exists: it keeps MFA and needs nothing created in Authentik.

There’s no button on the login page, and there can’t be one. I wanted a “Sign in with Authentik” button. Emby’s web client renders the login disclaimer as plain text and loads custom CSS as a stylesheet, so nothing you put in the dashboard can become a link. The compromise is a bookmarkable URL, pasted into the disclaimer as text so people at least see it.

One DLL, but not too much of one. The plugin bundles its OpenID libraries into a single file so there’s nothing to install next to it. The first merged build exported those library types too, which can collide with the copy Emby ships. Making them internal fixed it, and moving off a deprecated version of the library happened in the same week.

An empty required group refuses everyone. I chose that on purpose. A server whose operator hasn’t said who may sign in hasn’t said anything, and the safe answer to nothing is nobody. Same reasoning for another rule: plain HTTP and native password sign-in can’t both be on, because this server will not put a password on the wire in cleartext.

Then the licensing took longer than the plugin. A small service that sells a code, activates a server, and signs releases. Its own problems were the ordinary kind: the buy button opened a popup that browsers blocked, then the page’s own security policy blocked the checkout, Docker Compose ate a password hash because it contained a dollar sign, and the CI runner didn’t trust the internal registry’s certificate. The one decision worth copying is that the signing keys don’t live on the internet-facing service at all. The service can mint licences and can’t ship code. The machine that ships code holds no customer data.

Setting it up

Roughly ten minutes if Authentik already runs. Hostnames below are examples.

1. Create the provider in Authentik. An OAuth2/OpenID provider with the redirect URI set exactly to:

https://emby.koper.dev/sso/callback

Bind an application to it with at least the openid scope, and make sure the provider emits the groups claim. That last part is a scope mapping, and it’s the step people miss: without the claim, nobody gets in.

2. Install the plugin. Download the one DLL (direct download, or follow the instructions on GitHub), check its checksum, drop it in Emby’s plugins folder, restart Emby.

3. Configure it. Dashboard -> Plugins -> Authentik SSO. Fill in the issuer URL, the client ID, the client secret if you made a confidential client, and set the required group straight away.

4. Assign each user to the plugin. For a normal user it’s a dropdown on their profile: Login provider, Authentik SSO. For administrators the dropdown is hidden, so it’s one API call with the user’s full policy and this field:

"AuthenticationProviderId": "Emby.Sso.Auth.SsoAuthenticationProvider"

Keep one administrator on Emby’s own provider. That’s your way back in.

5. Hand out the link. Users bookmark the start URL, or open the PIN URL on their phone when they’re setting up a TV:

https://emby.koper.dev/sso/start
https://emby.koper.dev/sso/pin

Two honest notes

It’s paid software with published source. You can read every line on GitHub, and you can get a free licence to test it by asking. A licence is checked on your own machine, and if it ever expires the only thing that stops is new sign-ins. Nobody gets locked out and nothing is deleted. I wrote down exactly what the plugin sends home, and it’s short: a server ID and a licence fingerprint, once a day, and you can switch that task off.

The PIN flow is unit-tested, not TV-tested. Browser sign-in and the Emby behaviour above were verified against a live Emby 4.9.5.0 server. I use the plugin myself and the PIN workflow does really work and because “built and tested” and “seen working” aren’t the same thing i use my own stuff. Just to make sure it works.

What’s next

More on how Authentik itself is set up in my lab is coming in its own post. The rest of the stack is on the Homelab page.