---
title: "One Sign-In, Many Tools: Letting Our Tools Trust Each Other Without Sharing a Secret"
description: "Single sign-on across small internal tools: a sixty-second signed note that says who somebody is and nothing about what they may do, a return address that is a lookup and never a parameter, an algorithm that is pinned rather than read, and a file of test vectors that made two halves built separately meet without surprise."
canonical_url: "https://www.truthpromoters.com/help/one-sign-in-many-tools"
type: "help"
section: "Help Center"
keywords: "single sign-on small internal tools, identity provider relying party, signed token identity only no roles, open redirect registry control, login CSRF state parameter, alg none algorithm confusion pinned, JWS Ed25519 test vectors byte for byte, single use token jti spent before session"
---

# One Sign-In, Many Tools: Letting Our Tools Trust Each Other Without Sharing a Secret

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

1. 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 that `state`.
2. 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.
3. 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 `state` echoed
   back — signs it, records that it did so, and sends the browser to the tool's return address.
4. 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.

1. 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.
2. 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.
3. 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.

## Related topics

- [The Certificate Authority That Merely Answers](https://www.truthpromoters.com/help/a-ca-that-merely-answers) — Every way an internal certificate authority can look finished while quietly doing nothing useful
- [The Thing You Didn't Change: Why Every Check Needs a Control](https://www.truthpromoters.com/help/the-thing-you-didnt-change) — A machine broke the moment we changed it — except it had been broken for days, and the only thing that proved it was a machine we had deliberately left alone
- [Technical Writings](https://www.truthpromoters.com/help/technical-writings) — Notes on the systems behind this site — what broke, what we learned, and what misled us
