FOIA ConsoleBorn Between 2 Generals Open the console →

Authentication

The thing being protected is not the data.
It is the ability to send mail as you.

Anyone who could reach an unprotected send endpoint could post letters to federal agencies over a domain authenticated with SPF, DKIM and DMARC, in someone else’s name. That is the whole threat, and it is why sending is its own scope rather than part of write.

What changed

There was no authentication. The previous version documented "put it behind auth" and shipped listening on every interface — and a default that is only safe if you read the README is not a safe default.

What is there now is not a login system. It is a small set of decisions, each of which exists because of a specific way this could go wrong.

Three scopes, and why send is on its own

read

Agencies, requests, the dashboard, escalation letters, open signals. Everything that only looks.

write

Create drafts, record events and tolls, mark a portal submission, confirm or dismiss a signal. Everything that changes the record but touches nobody outside it.

send

One endpoint. It is separate because it is the one that spends your domain's sending reputation and puts your name on a letter to a federal agency.

A tracking-only console does not need send, and the token tool says so when it mints one without it. That is the point of the split: the credential sitting in a browser tab, or in a cron job, or in a colleague's hands, can be exactly as powerful as the job requires and no more.

A request with a valid token but the wrong scope gets 403 with the scopes it has, the scope it needed, and — for send — the reason the line is drawn there. The denial is written to the audit trail.

Tokens are hashed at rest

A token is foia_ followed by 32 bytes of crypto.randomBytes in base64url. What is stored is its SHA-256 hash. The plaintext is returned exactly once, at creation, and cannot be recovered — if it is lost, mint a new one and revoke the old.

Two consequences follow, and both are the reason to do it this way:

  • A stolen database does not yield a usable credential.
  • A backup of that database contains nothing to leak.

The presented token is hashed and looked up by that hash. There is a timingSafeEqual after the lookup as well, and it is worth being precise about what it does: it compares the digest against the row that was selected by that digest, so it can never return false. It is belt and braces, and it is not where the security comes from.

The security comes from entropy. Two hundred and fifty-six random bits, stored only as a digest. The database index comparison is a plain memory compare and is not constant time — but the only thing its timing could leak is how many leading bits of a SHA-256 output happen to match, and turning that into a token would require producing a preimage. A reviewer who says the lookup is not constant-time is technically right and practically wrong, and it is better to say so than to claim a property the code does not have.

The inbound webhook secret is a different case, and there the constant-time comparison is real: both sides are hashed first, which also makes both buffers exactly sixty-four bytes, so the comparison cannot throw on a length mismatch or leak the secret's length. And it is read from a header, never a query string — a secret in a query string ends up in access logs, proxy logs and Referer headers.

The default

Loopback, unless you decide otherwise on purpose.

The server binds 127.0.0.1 unless HOST says otherwise. Exposing it to a network is a decision an operator has to make deliberately — and if they make it without minting a token, the process refuses to start rather than starting open.

The refuse-to-start guard

Before the port is bound, two configurations are rejected outright:

Refusing to start: HOST=0.0.0.0 is reachable from the network and no API
token exists.
Mint one first:  node src/token.js create "console" --scopes read,write,send
Refusing to start: AUTH_DISABLED=1 with HOST=0.0.0.0.
Disabling auth is only allowed on loopback. Anyone who can reach this port
could send mail from your authenticated domain.

AUTH_DISABLED=1 exists for development and it prints a warning every time it is used. It is permitted only on loopback. There is no configuration in which this server listens on a public interface with authentication switched off.

Per-token send rate limiting

A leaked token should not be able to empty a sending reputation into the nearest spam filter before anyone notices. The send endpoint is rate limited per token, in a rolling one-hour window, defaulting to thirty messages — a person filing requests by hand does not send thirty an hour.

Over the cap the endpoint returns 429 with a Retry-After header and a message that names both possibilities, in the right order:

"This token has sent 30 messages in the last hour. Raise SEND_RATE_PER_HOUR if that is genuinely your volume, or check whether the token has been taken."

Rate-limited attempts are audited too, so the pattern is visible afterwards rather than only at the moment it happens. Two behaviours are worth knowing before you tune the number: the budget is charged when the endpoint is entered, so a 409 for a portal-only agency spends one as well — the cost is the call, not the delivery. And the counter lives in process memory, so a restart clears it.

CORS

CORS_ORIGIN defaults to *. There is no cookie session here and no ambient credential, so a permissive policy is not the CSRF hole it would be with cookies — a browser will not attach a bearer token to a cross-site request by itself. Tighten it anyway: set it to the exact origin serving your console, so a hostile page cannot even attempt calls against a server the victim's browser can reach. With a stolen token that attempt would succeed.

One catch worth knowing before you lock it down: a console opened as a local file:// page sends Origin: null, which no allowlist matches. For that case keep * and keep the server on loopback, or serve the console from an origin you can name.

The audit trail

"Who sent mail from my domain" has to have an answer. Every send writes a row naming the token id, the token's name, the request, the agency, the recipient address and the provider's message id. So do denied-scope attempts, rate-limited attempts, rejected inbound webhooks, and every signal confirmed or dismissed.

$ node src/token.js audit 20

  when                 token           action           request
  2026-08-19T14:02:11  press desk      sent             r_8fa1c2  ojp → FOIAOJP@usdoj.gov
  2026-08-19T14:02:44  press desk      send-rate-limit  r_8fa1c2  {"cap":30}
  2026-08-19T15:10:03  read-only cron  denied           —         {"scope":"send"}

The token administration tool is deliberately small — create, list, revoke, audit. list shows every token's scopes, when it was last used and whether it is revoked; a token that has never been used and a token that was used an hour ago look different at a glance, which is most of what you want from that view. Revocation takes effect on the next request.

What this does not do

There are no users, no sessions, no roles and no password reset. A token is a bearer credential: whoever holds it is authorised, which is why the rate limit, the audit trail and the scope split all exist. This is the right shape for a single-process service that one person or one small team operates. It is not the right shape for a multi-tenant product, and it does not pretend to be one.

How this works

0:54 · narrated · captioned · served from this site, and nothing is sent anywhere when you press play. Prefer to read it? The transcript is below.

Transcript
AtSectionWhat it says
0:02The threatThere was no authentication. Anyone who could reach the API could post letters to federal agencies over your authenticated domain, in someone else's name.
0:12Three scopesRead, write, and send. Send is separate on purpose. It is the only one that spends your domain's reputation, and a message that has left is gone.
0:22Hashed at restTokens are stored as hashes and shown to you once. A stolen database yields no usable credential, and a backup of it contains nothing to leak.
0:32Safe by defaultThe server binds loopback. Expose it to a network with no token minted and it refuses to start, rather than starting open and documenting the risk.
0:41Who sent thatEvery send writes an audit row naming the token behind it. Who sent mail from my domain, and when, has an answer that outlives your provider's logs.