Single sign-on is a solved problem. You redirect to an identity provider, it tells you who the person is, you mint a session. Every framework has a library for it.
Then you try it on an app that encrypts everything in the browser, and the whole thing falls over.
The claim that breaks SSO
MindMapVault derives your encryption key from your passphrase, in your browser, and never sends it anywhere:
master_key = Argon2id(passphrase, salt)
auth_token = HKDF(master_key, "crypt-mind-auth-v1") // the only part the server sees
Everything else hangs off master_key. Your private keys are encrypted under it. Your vaults are encrypted under those. The server stores ciphertext and a hash of auth_token, and that is the entire extent of what it knows.
Now add an identity provider. Entra says this is alice@example.com. Google says the same. Both are telling the truth, and neither one helps, because an OIDC token is an assertion about identity — it is not, and cannot be, the key that decrypts Alice's data.
The key has to come from somewhere. There are exactly three somewheres:
- Something the user types — a passphrase.
- Something a device holds — a passkey, or a key in browser storage.
- Something your organisation holds — escrow.
Option 3 is the one that makes SSO a one-click experience, and it is the one that quietly ends zero-knowledge: if the org can unwrap the key, so can the server, so can a subpoena, so can whoever gets into your KMS. Most products that advertise "SSO with end-to-end encryption" have picked option 3 without saying so.
We picked 1, then made it rare with 2.
What the user sees
First sign-in through the provider. Redirect, consent, back. Then one screen: choose a username and a vault passphrase.
Every later sign-in. Straight through. No passphrase — because the browser already holds a key that can unwrap it.
A new laptop. Passphrase once, then that machine is remembered too.
So the passphrase is typed roughly as often as you buy a computer.
The flow, end to end
The OIDC half is ordinary — authorization code with PKCE, a single-use state, a nonce bound to the request. The interesting part is where it stops:
browser our server identity provider
| | |
| click "Continue" | |
|----------------------->| store state+nonce+PKCE |
| 307 redirect | |
|<-----------------------| |
| | |
| authenticate ------------------------------------>
| <------------------------------ redirect + code
| | |
| code + state | |
|----------------------->| exchange code --------->|
| |<-------- id_token (JWT) |
| | |
| | verify: signature, |
| | issuer, audience, |
| | expiry, nonce |
| | find or create account |
| tokens in fragment | |
|<-----------------------| |
| | |
+-- the server is done here. the vault is unlocked
locally, with a key the server has never seen.
Two details in that picture are easy to get wrong. The ID token is verified on signature, issuer, audience, expiry and nonce — skip any one and the flow becomes a way to sign in as anybody. And the tokens come back in the URL fragment, not the query string, because browsers never send a fragment to a server: it stays out of access logs, proxy logs and Referer headers, and the page clears it from history immediately.
The account created at the end of that has no password and no keys — it cannot open anything yet. That is what the enrolment screen is nor, and it is why the username is asked for there rather than lifted from a provider claim: a name taken from an IdP can collide with an existing local account, and resolving that collision by merging is an account takeover with extra steps.
The insight that makes it cheap
Here is the part worth stealing for your own app.
We nearly redesigned the whole key hierarchy for this. We did not need to. Look again at what the system actually depends on:
const aesKey = await deriveMasterAesKey(masterKey);
const classicalPriv = await aesDecrypt(aesKey, bundle.classical_priv_encrypted);
const pqPriv = await aesDecrypt(aesKey, bundle.pq_priv_encrypted);
Nothing here cares how you got those 32 bytes. deriveMasterAesKey just imports them as an AES key. The Argon2 derivation is one way to obtain them, not the definition of them.
So instead of changing the derivation, we store additional encrypted copies of the same master key, one per unlock method:
master_key (32 bytes)
never leaves the browser
^
+-----------------+-----------------+
| | |
derived from unwrapped by unwrapped by
| | |
+------------+ +-------------+ +-------------+
| passphrase | | device | | passkey |
| (Argon2) | | key | | (WebAuthn |
| | | (IndexedDB) | | PRF) |
+------------+ +-------------+ +-------------+
nothing server holds server holds
stored ciphertext ciphertext
One table:
CREATE TABLE unlock_methods (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- 'device' | 'webauthn-prf'
label TEXT NOT NULL, -- "Chrome on Windows"
wrapped_master_key TEXT NOT NULL, -- ciphertext the server cannot open
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ
);
No migration. No vault re-encrypted. The passphrase path is untouched — it
still derives, exactly as before. Everything else is additive, which is also why adding passkeys later is a second value in kind rather than a rewrite.
Trusting a device
The wrapping key is generated in the browser and never becomes bytes anyone can read:
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // extractable: false - this is the whole trick
['encrypt', 'decrypt'],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, masterKey),
);
await store.put({ userId, methodId, key }); // IndexedDB holds the CryptoKey
extractable: false means crypto.subtle.exportKey throws. The key can
encrypt and decrypt, and that is all — it cannot be serialised, logged, or
posted to an endpoint, even by code running on the page.
What goes to the server is iv || ciphertext. It is opaque. We assert that in
a test rather than merely believing it:
const bytes = Buffer.from(wrapped_master_key, 'base64');
expect(bytes.length).toBe(12 + 32 + 16); // nonce + key + GCM tag
Exactly nonce, key and tag — no room for anything in the clear.
Unlocking silently
On load, before showing any prompt:
const record = await loadDeviceRecord(userId);
if (!record) return; // ask for the passphrase
const { wrapped_master_key } = await api.getWrappedMasterKey(record.methodId);
const masterKey = await unwrapWithDevice(record, wrapped_master_key);
if (!masterKey) {
await forgetDevice(userId); // stale - drop it
return; // ask for the passphrase
}
await openWithMasterKey(masterKey);
Every failure falls through to the prompt. Not being remembered is the normal case, not an error, and a device that cannot unwrap is a device whose local key is now useless — so it is deleted rather than retried on every load.
The two things that will bite you
Rotation invalidates every stored copy. When someone changes their
passphrase, master_key changes, and every wrapped copy becomes undecryptable noise. If you leave those rows, a device trusted yesterday fails today with nothing on screen to explain why. So rotation deletes them all:
// The rotation has already committed, so this cannot abort it. A stale row
// costs one failed silent unlock and a passphrase prompt - the safe direction.
match state.db.delete_all_unlock_methods(&auth.0).await {
Ok(0) => {}
Ok(removed) => tracing::info!(removed, "cleared trusted devices after rotation"),
Err(error) => tracing::warn!(?error, "could not clear unlock methods"),
}
That is also the right behaviour when the rotation happened because the old passphrase leaked.
Never link accounts on email. An OIDC token carries sub and usually
email. It is tempting to match on the address — it is human-readable and
already in your users table. Do not. An address gets reassigned when someone leaves a company, and the next holder of alice@example.com would inherit Alice's vaults. Link on (provider_id, sub), and store the email only when the provider says it verified it:
fn verified_email(claims: &IdTokenClaims) -> Option<String> {
match (claims.email.as_deref(), claims.email_verified) {
(Some(email), Some(true)) => Some(email.to_string()),
_ => None, // a provider that says nothing has not checked it either
}
}
What this is not
A trusted device is convenience, not security. Anyone who can use that
browser profile can open the vaults. That is the honest trade, it is why the checkbox is off by default and says so in plain words, and it is no weaker than leaving a session signed in — which is what people do anyway.
And there is no recovery. No passphrase, no admin reset, no escrow. Lose every device and forget the passphrase, and the vaults are gone. That is not a gap we have failed to close; it is the same sentence as "the server cannot read your data", said from the other side.
Where it leaves us
Federated sign-in that authenticates the session, a passphrase that unlocks the data, and a device key that means you rarely type it. Three secrets doing three different jobs, none of them held by the server.
Next is WebAuthn PRF — deriving the wrapping key from a passkey instead of browser storage. With a syncing passkey provider that is genuinely passwordless across devices, and thanks to the table above it is one more row, not another redesign.
MindMapVault is an end-to-end encrypted mind mapping tool. Everything described here — federated sign-in, the enrolment step and trusted devices — ships in the next release of the self-hosted server, which is open source under AGPL-3, so you can read the parts this article only summarises.
Top comments (0)