What "off by default" actually means
Claim layer off is not a dark feature — it is two missing environment variables, 503 route guards, no database connections and no crypto path. What would be enabled, exactly, and how to verify the off state yourself.
"Your data never leaves your device — unless you turn on the optional account layer."
Every local-first product says some version of this sentence, and the sentence is
where the trust either starts or ends, because off by default can mean anything
from "we encrypted it before storing it" to "the endpoint exists and one env var
lights it up." This post takes the phrase apart at the file level: what has to be
true before the claim layer exists at all, what an unconfigured deployment actually
does when a request reaches a claim route, what would be stored if it were ever
enabled, and how anyone can check the claim instead of taking it.
It is a short post on purpose. The claim layer is a small, bounded thing with two
environment variables, a handful of routes and three database tables — small enough
to describe completely, which is the only version of this page worth writing.
Off by default means the layer is unconfigured, not disabled: withoutDATABASE_URL the feature does not exist for the deployment — claim routes answer503 "Accounts are not enabled on this deployment", no database connection is ever
opened, the encryption helper refuses to produce a key while off, and the UI shows
the friendly notice instead of sign-in. Enabling requires both DATABASE_URL
and CLAIM_SECRET; setting one without the other is a startup-time error, not a
quiet half-on state. What would be stored if you did enable it: users, sessions and
profiles only — argon2id password hashes, AES-256-GCM-encrypted email and snapshot
fields, opaque session-token hashes. Your archive's contents are not part of that
list, because analysis never routes through the server either way.
- 1minthe two-variable switch and the half-configured error.
- 3minwhat each unconfigured path does, route by route.
- 5minwhat "on" would store, so the off state has a concrete shape.
The switch is two variables, and they are fussy
The claim layer activates when DATABASE_URL is present — that is the code-level
switch — and the moment it is present, a non-empty CLAIM_SECRET becomes
mandatory: the secret lookup throws ('CLAIM_SECRET is required when
DATABASE_URL is set') instead of falling back.1 There is deliberately no
default secret, no generated-per-boot key, and no "works without it" mode. A
deployment can be fully off, or fully on; a half-configured deployment refuses to
run, which is the only safe failure direction for something that encrypts fields.
Both variables live in the local .env file (or the deployment host's environment),
and .env is gitignored — the secrets exist only where you put them, never in the
repository.2 The stack has no server code beyond these routes: the
application is a client-side app plus claim-layer route handlers, which is why the
off state is so clean — there is no background worker, cron or queue that could
quietly need the database.
What unconfigured actually does, path by path
The off state is not a banner; it is behaviour at every layer that would otherwise
care:
- Routes. Each claim endpoint checks the feature flag first. Reaching
POST /api/claim/loginon an unconfigured deployment returns503with{ ok: false, error: 'Accounts are not enabled on this deployment.' }— before any
database access, before any hashing work.3 The guard runs structurally at
the top of the handler, so an unconfigured deployment cannot leak a partial
code path: there is nothing behind the 503. - Crypto. The secret helper returns a sentinel while off — and the function that
derives real encryption material throws if it is ever called in that state,
so a bug that forgot to check the flag fails loudly instead of encrypting under a
placeholder.4 This is the difference between a feature that is switched off
and one that is absent: absent code paths raise. - Database. No
DATABASE_URL, no connection string, no driver instance. Theclaims-disabled world contains zero Postgres traffic — not "read-only", not
"empty pool": no pool exists. - UI. Sign-in surfaces render the claims-disabled notice rather than a working
form; the rest of the app — parsing, analytics, the ask-anything engine — is
unaffected, because none of it was ever server-dependent. The local-first post
walks the processing side; this post is about the switch.
The verification is two requests wide: hit a claim route and read the 503; unset the
variables and confirm the app's full feature set still works. Neither needs trust —
they need a browser.
What "on" would store, so the off state has a shape
You cannot judge an off switch without knowing what it controls, so here is the
entire inventory of the layer it gates — three tables:5
| Table | Holds | Deliberate properties |
|---|---|---|
| `users` | Account: username, encrypted email (`emailEnc`), HMAC email index (`emailHmac`), argon2id `pwHash`, verification/reset fields, `deletedAt` | Email encrypted at rest; plaintext never stored; HMAC index allows lookup without decryption |
| `sessions` | `userId`, `tokenHash`, expiry, last-seen | Opaque random tokens — only the hash is stored; cookies httpOnly/Secure/SameSite in production |
| `profiles` | Username, `encryptedPack` (AES-256-GCM nostalgic snapshot), visibility/design fields | Pack contains your own derived aggregates only; no messages, no third-party data |
Passwords are argon2id hashes; the encryption and HMAC keys derive fromCLAIM_SECRET with domain separation; profiles hold aggregates, never raw
message or contact content.6 The sync tier — a separate, additionally
opt-in feature — is a fourth kind of thing entirely (encrypted lean blobs, media
never stored) and is documented in the security post's custody section.
That is the whole thing the phrase "off by default" is withholding: not a shadow
profile, not a warm database, not a reserved ID — three tables' worth of account
plumbing that does not exist until you supply both keys. And the default posture,
for every deployment including this one you are reading, is the other column:
nothing configured, nothing listening, nothing stored.
Can I use the app fully without ever configuring anything?
Yes — that is the default. Archive parsing, all analytics, comparisons, the
ask-anything engine and local exports run in the browser with no configuration and
no server dependency. The claim layer only adds optional account features
(claims, hosted snapshot, sync); nothing in the core reading flow routes through it.
What happens if I set only DATABASE_URL and forget CLAIM_SECRET?
The deployment refuses to start handling claims: the secret lookup throws
('CLAIM_SECRET is required when DATABASE_URL is set') — a loud, at-first-use error
rather than a silent fallback to an insecure default. The fix is supplying the
secret or removing the URL; there is no third, degraded mode.
Does 'off' mean my export is never sent anywhere?
It means no part of the app has anywhere to send it: parsing happens in your
browser, and the only server routes in the product are the claim/sync handlers that
do not exist while unconfigured. The local-first post includes the network-off
verification — load an archive with networking disabled and watch nothing happen.
If I enable it later, does anything retroactively upload?
No. Enabling the layer creates account plumbing — users, sessions, profiles — and
the archive still never routes through the server; profile sync, if you also enable
and use it, is a separate opt-in that ships a lean encrypted pack, never your media
and never raw messages. The security post documents that boundary in detail.
Where do these variables live, and can they leak from the repo?
In .env locally (gitignored) or your deployment host's environment settings.
They are never committed: .gitignore excludes .env and .env.*, and no source
file contains a default value — the code treats a missing secret as an error state,
not as a string to fall back to.
Questions this comes up
The local-first post for what runs on the device; the security post for custody,
crypto and erasure in depth; the counting post for why none of the numbers depend on
this layer either.
1: apps/web-next/src/lib/claim/env.ts — claimsEnabled() keys offDATABASE_URL; the CLAIM_SECRET lookup throws 'CLAIM_SECRET is required when
DATABASE_URL is set' when the layer is on but the secret is empty.
2: Root .gitignore — .env, .env.* excluded (!.env.example
excepted); no default secret values exist in source.
3: apps/web-next/src/app/api/claim/login/route.ts — feature flag checked
first; unconfigured deployments answer 503 'Accounts are not enabled on this
deployment.' before any database access.
4: env.ts — the offline sentinel is never usable for real crypto: the
key-deriving helper throws while the layer is disabled, so a forgotten flag check
fails loudly.
5: db/schema.ts — users, sessions, profiles (plus the separately
opt-in sync tables, which are not part of the claim layer's three).
6: SECURITY.md claim-layer sections — argon2id password hashing,
AES-256-GCM field encryption with CLAIM_SECRET-derived keys and domain-separated
HMAC email index, opaque session tokens stored as hashes, profile packs containing
owner-side aggregates only.
Footnotes
- env
- gitignore
- route
- crypto
- schema
- crypto2