Workbench
My DNS Editor Was Keeping the Keys in the Browser

An early version of my DNS editor could leave TSIG key material in browser storage. A later version could show the correct zone name and still send an edit through the wrong DNS view. The interface looked convenient in both cases because it had erased two distinctions that DNS operations cannot afford to erase: where a secret lives and which server a change is meant for.
The current code and complete history are public in ttpears/snap-dns. I maintain the project. Snap-DNS is a React and Express interface around BIND’s dynamic-update tooling, with staged record changes, snapshots, access controls, and audit logs.
A TSIG key is not ordinary browser configuration
TSIG authenticates a DNS transaction with a shared secret. The current TSIG standard says that the key must be protected like a private key because anyone who obtains it may be able to impersonate an authorized party.
That makes a browser-side convenience path a security boundary. Snap-DNS had already gained encrypted server-side key storage, but old client models and fallback paths still knew how to carry a key value. An existing installation could also retain a legacy keys array inside localStorage.
localStorage persists beyond the tab and is available to scripts running in the same origin. The HTML storage specification defines it as the storage area associated with the window’s origin. That is useful for a display preference. It is a poor resting place for the shared secret that authorizes DNS updates.
The important repair was not “encrypt the same browser field.” I removed the secret from the frontend’s model entirely.
export interface AvailableKey {
id: string;
name: string;
server: string;
keyName: string;
algorithm: string;
zones: string[];
}
The browser receives metadata that it needs to present a choice. It does not receive the secret. KeyContext loads keys only from the authenticated backend API, and the saved selection contains only { keyId, zone }. A network failure now produces no fallback key list instead of quietly reaching for an old client-side copy.
Removing the new path was not enough
Stopping future writes to browser storage would still leave existing installations holding the old data. The configuration loader therefore performs a one-time cleanup:
if ('keys' in parsed) {
delete parsed.keys;
localStorage.setItem('dns_manager_config', JSON.stringify(parsed));
}
The settings importer also changed. Legacy exports may contain full key records. Imports now route those records through the server-side TSIG key API. Exports contain key metadata but never the secret. Dead fallback types that modeled client-held key values were deleted so a future feature cannot casually rediscover them.
Tests seed a fake legacy secret, mount the configuration provider, and confirm that the stored value is removed and never written back. Other tests confirm that key metadata returned to components has no secret property. The full removal is in the browser-key retirement commit.
Then the zone name picked the wrong DNS server
The second boundary problem was more operational than secret storage. BIND can serve different answers for the same zone name through different views. An internal client and an external client might both ask for example.com and receive intentionally different records. BIND’s view documentation describes exactly that arrangement.
Snap-DNS could configure more than one TSIG key for the same zone. The interface let me select a key, but the zone API request identified only the zone name. The backend called a helper that found a key for that name and returned the first match.
The visible selection therefore was not authoritative. If an internal and external key both served the zone, an edit intended for the internal view could land on the external server—or the reverse—depending on which matching key the backend encountered first.
This is worse than a form that shows the wrong label. The interface could show the right label, accept a valid change, authenticate it with a valid key, and successfully update the wrong view. Every individual component could look functional while the end-to-end target was false.
The key became part of the address
The repair made the selected view explicit from the browser to the DNS call. Reads send keyId as a query parameter. Adds, deletes, updates, and batches carry it in the request body. Requests without it are rejected instead of guessed.
The backend then applies three separate checks:
- The current user is allowed to use the selected key.
- The key exists.
- The key is configured to serve the requested zone.
Only after those checks does the service decrypt the stored secret and construct the configuration passed to nsupdate. The client identifies a key; it never supplies key material.
const resolved = await resolveZoneKey(
user,
zone,
req.body.keyId
);
if (!resolved.ok) return res.status(resolved.status).json(...);
Pending edits also changed identity. They are grouped by the pair (zone, keyId), not by zone alone. Two edits for the same name but different views become two batches, each sent with its own key. Snapshots record the key ID used to capture them, then use that same view for comparison and restoration.
The implementation and regression suite landed in the explicit-view commit. Its integration tests create internal and external keys for one zone, verify dispatch to each server, reject a missing key ID, reject a key outside the caller’s allowlist, and reject a key that does not serve the zone.
A failed read once looked like deleted keys
A later bug reinforced the same lesson about interfaces hiding important states. The strict key-management rate limiter covered the entire TSIG router, including GET /api/tsig-keys. A burst of legitimate key edits could rate-limit the next list request.
The settings page turned that failed fetch into an empty array and rendered “No TSIG keys configured.” Nothing had been deleted, but the screen made a transport failure visually identical to an empty system.
The limiter now applies only to create, update, and delete operations. Reads use the general limit. The panel also has separate loading, failed, and empty states; an error says the keys could not be loaded and explicitly says they were not changed. That is not cosmetic wording. It stops an operator from reacting to a false deletion by recreating keys or changing a working DNS setup.
The JSON stores needed their own boundary
Snap-DNS stores users, encrypted TSIG keys, API-token hashes, snapshots, webhook settings, and SSO settings in server-side files. The original services wrote their JSON independently. A crash during a write could leave a partial file, and concurrent writers could read the same old state and overwrite each other’s changes.
The current shared writer serializes operations per target file. It writes a temporary file, flushes it with fsync, closes it, and renames it over the destination. The rename prevents readers from seeing a half-written JSON document, while the per-file queue prevents overlapping updates from clobbering each other. Tests force write failures, concurrent updates, and temporary-file cleanup. The change is in the atomic-persistence commit.
What I verified for this article
I checked the current version 3.4.0 source, the relevant commits, and the regression tests. I then installed semver-compatible dependencies and ran both suites locally:
- Frontend: 23 suites and 254 tests passed.
- Backend: 39 suites and 369 tests passed.
- The frontend type check and production build completed, with existing lint warnings.
I did not run the full Docker/BIND end-to-end lab, so I am not presenting these as live DNS measurements. The backend production compile also hit TypeScript portability errors under pnpm’s dependency layout; the repository is locked and normally built with npm. I am counting the 623 passing unit and integration tests as verified here, not quietly turning the local package-manager mismatch into a clean-build claim.
The rule I am carrying forward
A control panel should not merely collect enough information to make a request. It should preserve the identity of the thing being controlled all the way to the final operation.
For Snap-DNS, that means the browser may remember a key ID but never the key, and a zone name is not a complete target when multiple views exist. A failed fetch is not an empty system. A successful update is not correct unless it reached the intended view.
Those distinctions made the interface a little stricter. They also made it honest.
Disclosure: I maintain Snap-DNS. It is open-source software under the MIT license. Repository and standards links are not affiliate links. The hero image is an editorial illustration.