All work WEB PLATFORM / INTERNAL TOOLS internal

14  /  WEB PLATFORM / INTERNAL TOOLS

KeyHub

I could not say which services a given site depended on, and my API keys kept going missing.

September 2026React 19TypeScriptVite 8Web Crypto APIAES-256-GCMPBKDF2-SHA256localStorageHand-built SVG

The challenge

My third-party services were spread across several projects with no record of which one belonged to what. I could not see which services a given website or app depended on, and the keys themselves went missing. Answering either question meant asking someone.

What I built

I built KeyHub in a day. Four linked record types carry the whole model: a team owns a project, a project uses a service, a service holds a credential, so "what does this site depend on" is answerable from either end. I seal secrets with AES-256-GCM under a key stretched from a master password by PBKDF2-SHA256 at 600,000 iterations. Everything lives in the browser's localStorage behind a single Repository interface, with JSON export and import for backup and the secret values still sealed inside the file.

How it works

HUMAN DERIVATION ON SCREEN Master passwordentered by a person PBKDF2-SHA256600,000 iterations Key held in a refnon-extractable, not state Secret unsealedAES-256-GCM, on screen ON LOCK Lock drops the refone assignment, key gone Cleared mid-rendergone before commit Locked, not hidden the whole app still renders; only secrets are withheld
The mechanism, drawn from the build.

What it looks like

KeyHub — screenshot
Running locallyWEB PLATFORM / INTERNAL TOOLS

The detail

01

The key is a ref

I keep the derived AES key outside the reducer that owns the database. In React state it could be serialised into a devtools snapshot, or written to localStorage alongside everything else. I mark it non-extractable at derivation, so JavaScript cannot read the bytes back out either, and locking the vault is one assignment: drop the reference.

02

Cleared mid-render

When the vault locks while a secret is on screen, the field clears it with a render-phase state adjustment rather than an effect. React throws that render away and re-runs it, so the plaintext is gone before anything commits. I discard a decrypt still in flight when the lock fires rather than let it land in state.

03

Cross-tab sync

Two tabs stay in step through the storage event, but saving the incoming state fires that event back and the tabs bounce the same value forever. A ref marks state that arrived from another tab so the persistence effect skips exactly one write. I re-run the payload through the same migration the initial load uses, because another script on the origin could have written anything into that key.

04

Locked, not hidden

A locked vault still renders the whole app. Which service powers which project, what it costs and who owns it are not secret, and I store them unencrypted regardless, so hiding them behind the password would buy no real protection while blocking the main use case. I withhold only the secret values, and I wrote the reasoning into the code at the gate.

05

Two dependencies

The app ships on React and React DOM and nothing else. No router, no component library, no CSS framework, no chart library: I drew the sixty icons on a shared 24x24 grid and built the spend donut from stroke-dasharray circles. A day of work, and the build compiles clean at 408 kB, 118 kB gzipped.

The outcome

I built it and had it working in a single day: six screens, an encrypted vault, cross-tab sync, command-palette search, and export and import. There is no server, so it is a per-browser tool today rather than something the whole team shares. I put persistence behind one interface, which means I can add a backend later without touching a screen.