unif-id
Various tools I've worked on have required some form of user account or authentication, often with overlapping core requirements - generally meaning a database with user information, ID, username, email, whatever. Each tool would handle its own authentication, leading to duplication of data, multiple usernames and passwords, more stuff to remember and lose. I needed a unified authentication service, to help with the trade-off between security and user friendliness - one generally comes at the cost of the other.
Options that aren't passwords
Passwords shouldn't be re-used among different services, need to be of appropriate length and quality, and are generally annoying; I wanted something else.
I settled on emailed codes, with the intention of adding passkeys later, built in one service that every app signs in through - which here I'm calling unif-id.
| password-free | safe from link scanners | works across devices | phishing-resistant | no middleman | |
|---|---|---|---|---|---|
| passwords | no | – | yes | no | yes |
| magic links | yes | no | no | no | yes |
| emailed codes | yes | yes | yes | no | yes |
| passkeys next | yes | – | partly | yes | yes |
| authenticator apps | no | – | yes | no | yes |
| sign in with Google | yes | – | yes | partly | no |
Magic links were an obvious choice: enter your email and be sent a link, you click it and you're in. However, some email security systems, mostly on work and school accounts, open every link in a message to check it before you do, and that can use up a link that only works once. It's fixable - the link can open a page with a button instead - but a code avoids the problem entirely, and works inside a phone app too.
Emailed codes are the same idea, but instead of a link you get some numbers to type in. The cost is a bit of typing, but like a link it can still be phished - someone who tricks you into reading the code out to them has the same access you do.
Passkeys are the new shiny one, being pushed by Apple, Google and Microsoft - Google made them the default for personal accounts in 2023, and new Microsoft accounts have been passwordless by default since 2025. Your device makes a key pair for each site, the site only ever stores the public half, and the browser will only use a key on the site it was made for, so a convincing fake login page gets nothing. Passkeys would always need a fallback - a new or lost phone, or someone signing in on a tablet that isn't theirs needs a way in that isn't a passkey, which would have been emailed codes anyway.
Authenticator apps, the rotating six-digit codes, are a second factor rather than a replacement, so they still need something to sit behind. I wanted to avoid "Sign in with Google" or similar, not wanting to rely on third-party authentication services.
Codes, not links
You enter your email, and get sent a six-digit code to type in.
A number of tries is allowed when entering a code, since mistakes do happen. Entering a code is a single database update that only succeeds while attempts are left, and the response is the same whether or not an email belongs to an account, so it can't be used to enumerate users.
What the app gets back
When you want to log in to something it sends you to the id service with its client id, the return address, and a random state value. The return address has to exactly match one registered for that app, compared strictly to prevent an open redirect: tricking the service into sending a sign-in token to an attacker's site, which would be token theft. Once you've entered your code, the service sends you back with a token, and the app checks the state matches the one it sent, so nobody can slip it someone else's sign-in.
Tokens are similar to a JWT: a JSON payload and signature, each base64-encoded. Decoded, an example might look like this:
{
"iss": "https://id.example",
"sub": 7,
"aud": "antidote",
"iat": 1785400000,
"exp": 1785400060,
"username": "sam",
"display_name": "Sam",
"email": "sam@example.test",
"email_verified": true
}
Signed with Ed25519 and lasts sixty seconds, long enough for a redirect. Each app checks the signature with the service's public key, so it never has to call back to ask whether a token is real, and only the id service ever holds the private key. "aud" is the app the token was made for, and every app checks it's addressed to itself, so a token issued to ExampleTool can't be replayed against antidote. Tokens are only ever issued for a verified email, and that's enforced in one place for every app, not left to each of them.
The one deliberate difference from a real JWT is that there's no header, so there's no field saying which algorithm signed the token. JWT libraries have historically trusted that field - an attacker could set it to "none" and skip the signature check entirely, or swap the algorithm for a symmetric one and sign a forged token with the public key. Here the algorithm is fixed in the code, so there's nothing in the token to lie about.
algorithm is always Ed25519, so JWT alg confusion / alg=none attacks can't exist here
The key pair
The security of the whole thing hinges on one key pair. The id service generates it once, when it's first set up, using libsodium's random key generation: a private key that can sign tokens, and a public key that can only check them. The setup script refuses to overwrite a pair that already exists, because replacing it breaks sign-in for every app at once - changing keys has to be a deliberate act, never an accident.
The private key never leaves the id service. It's stored outside the web root and readable only by the service's own user. Whoever holds it could sign a token for any app as anyone, which is exactly why nothing else ever gets a copy - an app that gets hacked only gives up a public key, which can't be used to make tokens for itself or anything else.
The public key isn't a secret - the service publishes it - so sharing it doesn't need protecting from being read, only from being swapped. Each app has it copied into its own configuration once, when the app is registered, and checks every token against that pinned copy rather than fetching the key each time, so someone who managed to get between an app and the id service still couldn't hand it a different key. A forged token fails because changing even one character of the payload breaks the signature, and producing a new valid one needs the private key.
Apps without a browser
A phone app can't easily send you off to a web page and back, so the service also has a small JSON version of the same thing for apps to call directly: one request emails the code, another trades it for the same signed token. There are no cookies involved, so there's nothing for cross-site request forgery to attack. It deliberately shares the web version's code storage, single-use rules and rate-limit counters, so you can't bypass the limits on one by switching to the other.
Invite only
There's no sign-up page. Account creation is done by hand via a small command-line tool, with the email already marked as verified. antidote and anything else I make share the one account, but each keeps its own data.
Test process
I spun up a local test environment with Docker, and tried to break it with actual HTTP traffic. A sign-in code is read from a fake Mailpit inbox and entered, following the process through to the app. I wanted to test both the expected path and the unexpected, so I knew nothing would fall over if it received something malicious. On top of the obvious re-used codes or forged tokens I did some fuzzing (throwing deliberately mangled or broken input, wrong types, missing fields, odd characters or 5000 A's in every field) across all available API inputs, to try and catch every eventuality.
fuzz the **** out of it
The end result is a secure modular single sign-on service, usable across anything I want to plug into it, built on something people already have (their email) and without having to remember a load more passwords.