We run a handful of small tools for the people who work with us — a workshop where teaching material is written and reviewed, a research toolkit that finds pages in a library of Arabic and Persian books, and there will be more. Each one used to be a separate door with its own lock. This is an account of how we made them one door, and of the decisions that matter more than the code.
The short version: one of our tools already knows who everybody is, so the others now ask it. The tool that knows is daftar, the workshop — every account there exists because somebody vouched for the person behind it, and it is where passkeys and sign-in links live. When another tool needs to know who is at the door, it sends the browser to daftar, and daftar sends back a signed note that lives for one minute and says who this is. Nothing else. The tool that asked keeps its own list of members and decides for itself whether to let them in.
Note
Nothing here describes our own systems, which is a deliberate rule rather than an omission. No hostname, address, machine name or count of ours appears anywhere below. What follows is the mechanism and the reasoning, which are properties of the design rather than of where it runs.
Two questions, kept apart
Single sign-on is usually explained as convenience: sign in once, use everything. That is true, but it is the least important thing about it. The important thing is that it separates two questions that are easy to blur.
| the identity provider answers | each tool answers for itself |
|---|---|
| Who is this? | May they come in, and what may they do here? |
daftar answers the first. Every other tool answers the second from its own list. This is what makes it safe to add tools: a daftar account grants nothing anywhere else. Somebody can be a member of the research toolkit with no ability to write a word in daftar, and a daftar administrator is not an administrator of anything else. If we had let the identity provider also say "and this person is an admin", every tool would have inherited a permission model designed for a different job, and we would have discovered the consequences one surprise at a time.
Suspension follows the same logic. A suspended account cannot pass daftar's own sign-in, so it cannot obtain a note at all. Nothing downstream re-checks it, and nothing downstream can get it wrong.
The flow, and three decisions inside it
- A person opens the research toolkit. It has no session for them, so it invents a random
state, keeps it in a short-lived cookie, and sends the browser to daftar with two things: which tool is asking, and thatstate. - daftar checks that the person is signed in. If not, they sign in the ordinary way — a passkey or an emailed link — and land on their welcome page, where a card offers to open the tool that sent them. One extra click; we accepted it rather than build something cleverer on day one.
- daftar checks that the tool asking is one it knows. It then writes the note — who this is,
for which tool, valid for sixty seconds, with a random single-use id and the
stateechoed back — signs it, records that it did so, and sends the browser to the tool's return address. - The tool checks the signature, the claims and the
state, spends the single-use id, looks the person up in its own list, and — if they are on it — starts its own session.
Three decisions are hiding in that sequence, and each one is a control.
Only the tool may start it. daftar never writes a note on its own initiative; the card on
the welcome page is a plain link to the tool's start page, which creates the state and comes
straight back. That is why the state is required rather than optional: without it there is no
browser to bind the note to, and an attacker could start a sign-in in a victim's browser and finish
it themselves — the flow's version of cross-site request forgery.
The return address comes from a list, never from the request. The tool says which tool it is; daftar looks that up in its own registry and redirects to the address written there. An unknown tool gets a refusal with no redirect at all. If daftar instead accepted a return address from the request — which is the obvious way to write it, and the way many systems have been written — it would be an open redirect: a link that looks like ours and lands anywhere the sender chooses, carrying a signed note about you. The registry is small, boring, and the entire defence.
Sixty seconds, and single use. The note is a redirect, not a session. It is consumed a few hundred milliseconds after it is issued; if it were captured from a log and replayed later, it would already have expired, and if replayed quickly, its id would already have been spent. The tool spends the id before it starts a session, so the failure mode under a race is an annoying "start again", never two sessions from one note.
What is actually in the note
The note is a signed token in a standard format — three parts, separated by dots: a header, the
claims, and a signature over both. The header says which algorithm signed it and which of our
keys. The claims say who the person is (a stable account id, which never changes even if an
email address does), their email (used exactly once, to attach an invited-but-never-seen member
to their id), a display name if there is one, when the note was issued, when it expires, its
single-use id, the state, who issued it and which tool it is for.
That last claim is worth a sentence. The tool's own address is the audience. A note issued for the development copy of a tool names the development copy, and the production copy refuses it because the name is not its own. The mix-up is prevented by construction rather than by carefulness, and it costs nothing.
What is not in the note: any role, any standing, any permission, any flag. We wrote that rule down before we wrote the code, because "just add the role, it will save a lookup" is exactly the kind of shortcut that is convenient for a year and then impossible to remove.
The signature, and the one check that closes a whole class of attack
The note is signed with a modern elliptic-curve algorithm; daftar holds the private key and the tools hold only the public one. A tool cannot forge a note, and a tool that was compromised could not either.
The check that matters most is not the signature. It is this: the tool never reads the header to decide how to verify. It compares the algorithm name to the one it expects and always runs that one. This sounds pedantic until you read the history of the token format we use, which is full of systems that let the header choose — and were then handed a header saying "no signature needed", or "verify this with the public key as if it were a shared secret", and obliged. The whole class of those attacks is closed by refusing to be told what to do. Our verifier is tested against exactly those tokens, and refuses them all the same way it refuses a typo.
Two more refusals that a naive verifier omits: a note whose lifetime is longer than it should be is refused however well signed, so a bug on the issuing side cannot quietly mint long-lived credentials; and a note whose issue time is in the future is refused, so a wrong clock cannot either. Thirty seconds of tolerance covers honest clocks.
The same bytes on both sides
Here is the part we would tell anyone building this: agree the exact bytes, and test them, before either side writes a line of the other.
The signature covers the header and the claims as encoded, so if one side writes the claims in
a different order, or with a space after a colon, or escapes a non-Latin letter differently, the
signature is over different bytes and everything fails — correctly, and unhelpfully. The two halves
of our sign-in were built at the same time by different people. What kept them honest was a small
file of test vectors: for a throwaway key and a fixed set of claims, the exact token and the
verdict the verifier must reach — one valid, and one each for expired, wrong audience, wrong
issuer, corrupted signature, no signature, the wrong algorithm, a future issue time, an absurd
lifetime, a missing state, the wrong kind of token, an unknown key, and a replay.
The issuing side proves it produces the valid token byte for byte from the throwaway key — the signature scheme is deterministic, so this is an exact comparison, not a "verifies". The verifying side proves it reaches every verdict. And then a token the real issuing code produced was added to the file, so the verifier is tested against real output and not only against the vectors' author. When the two halves met, they simply worked. That is not luck; it is what the file was for.
The throwaway key is committed, on purpose, in both places, and the issuing side refuses to start in production under its name. A test key that could accidentally become a real one is a test key you will one day find in production.
Keys, and how they are replaced
There is one private key, under a short name. Each tool holds daftar's public keys by name. To replace a key: give every tool the new public key first, then switch daftar to signing with it, then remove the old name from the tools a day later. The order matters — done the other way round, there is a window in which daftar signs with a key nobody yet trusts, and every sign-in fails until someone notices. Notes live sixty seconds, so the overlap can be short.
The private key is printed once by the tool that generates it, and never again. There is no "show me the key" — if it is lost, a new one is made and rotated in. That is a small inconvenience bought in exchange for never having a copy of the key in a chat, a ticket, or a shell history.
What this is not
It is worth saying what we did not build, because the phrase "single sign-on" promises more than we mean by it.
- Sessions are per tool. Signing out of daftar does not sign you out of the research toolkit, and the reverse. The note bridged the two for a minute and is gone. If a tool ever needs "sign out everywhere", that is a small, separate mechanism — a message to each tool keyed on the account id — and not a change to this one.
- No shared cookie, no shared secret. Nothing is shared between the tools except a public key and an agreed format. A tool can be added, replaced or lost without touching the others.
- No new accounts. There is still no sign-up form anywhere; every account is still created by an invitation somebody sent. The sign-in became wider; the door did not.
Adding a tool
Three steps, and the third is the point.
- In daftar's registry: the tool's address, its return address and its start address, and a label for the welcome-page card. daftar checks the registry when it starts and refuses to run with a malformed entry — a mistake there should be a failed start, not a surprise in front of the first person to try.
- In the tool: daftar's public key, daftar's address as the expected issuer, its own address as the expected audience, and a verifier that follows the checks above — run against the same file of vectors.
- Nothing else. No new credential, no change to daftar's code, no new way in.
What we would say to someone building the same thing
Write the rule about what is not in the token before you write the token. Make the return address a lookup, never a parameter. Pin the algorithm. Agree the bytes with a file of vectors and make both sides pass it before you connect them. Spend the single-use id before you issue a session. And make the test key unable to boot in production, because one day it will try.
None of that is clever. All of it is the difference between a sign-in that is convenient and one that is also safe, and the second kind is the only kind worth having in front of a library.