DEV Community

Cover image for I Documented 29 Keys, Three Systems Lean on One Token
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Documented 29 Keys, Three Systems Lean on One Token

This blog's repository contains a file called .env.example. Its job is to tell anyone installing the site from scratch: fill in these keys and the rest works. Next to it sits a setup guide stating that you need, at minimum, four AI keys, both Turnstile values and an admin token. Together they make a promise: if this list is complete, the system stands up.

When the file came up for an unrelated task, one line in it did not immediately ring a bell. The question that stuck was the reverse one: is every key the code reads actually written in this file?

You cannot settle whether a template is incomplete by remembering. You have to count. I counted in three places and got three different numbers.

The count: 29, 97, 14

The first number comes from the document. .env.example defines 29 distinct keys:

grep -E '^[A-Z][A-Z0-9_]*=' .env.example | sed 's/=.*//' | sort -u | wc -l
# 29
Enter fullscreen mode Exit fullscreen mode

The second comes from the code itself. These are the distinct names read through process.env or import.meta.env under src/ and scripts/:

grep -rhoE '(process\.env|import\.meta\.env)\.[A-Z][A-Z0-9_]*' \
  --include='*.ts' --include='*.mjs' --include='*.astro' src scripts \
  | sed -E 's/.*env\.//' | sort -u | wc -l
# 97
Enter fullscreen mode Exit fullscreen mode

The third comes from the live server. The process serving the blog reads /etc/mustafaerbay.env, and 14 keys are set there. I did not look at any value — only the names at the start of each line.

29, 97, 14. Three sources that each claim to describe the configuration of the same system, and no two of them agree.

97 is a floor, not a set

An admission has to come next, because this piece argues "do not remember, count" and the counter itself is faulty. The grep above only sees the form process.env.NAME. If the code reads a variable any other way, it never enters the count.

Two shapes escape it. The first is optional chaining: in src/lib/runtime.ts the Turnstile secret is read as process.env?.TURNSTILE_SECRET, and that question mark keeps it out of the pattern. The second is a name computed at runtime:

envKey = lang === 'tr' ? 'ME_VOICE_ID_TR' : 'ME_VOICE_ID_EN';
const id = process.env[envKey];
Enter fullscreen mode Exit fullscreen mode

scripts/lib/elevenlabs.ts reads three voice IDs this way: ASKER_VOICE_ID, ME_VOICE_ID_TR and ME_VOICE_ID_EN. None of the three is in the template, none has a fallback value, and each throws at runtime if absent. They belong to precisely the category this article cares about most, and none of them made it into the count.

scripts/veo-prompt.ts picks up a provider key by the same dynamic method, but there it does no harm: the three names it reads (GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY) are both in the template and read plainly in other files, so they are already inside the count. A dynamic read is not automatically a blind spot; the blind spot appears when a dynamically read name occurs plainly nowhere else.

Widening the pattern to cover the question mark turns 97 into 98. Add the three dynamically read voice IDs and it is at least 101. "At least," because finding those leaks also came down to reading by hand.

Writing this off as a footnote would suit me. It cannot be: a recommendation to "derive the contract from the code" is coming later, and a check step that implements it literally would silently pass the exact class of bug it was built to catch.

What those 73 names actually are

By the narrow count, 73 of the 97 names in the code are missing from .env.example. Leaving the number there would have been easy — write "73 undocumented settings" and close the tab. It would also be dishonest.

Four of them are not mine to write. GITHUB_OUTPUT and GITHUB_STEP_SUMMARY come from the workflow runner, NODE_ENV from the runtime, and ONLY_SLUG is the slug parameter handed to the social posting workflow. They do not belong in the list.

That leaves 69 that someone could plausibly set. Most are harmless tuning knobs: FAQ_SLEEP_MS, AI_RATE_LIMIT_BREATH_MS, MAX_POSTS_PER_RUN and friends, each with a sane default in code, and the system behaves identically if you never touch them. Leaving those out is not a gap but deliberate restraint: put 97 lines in a setup file and nobody reads any of them.

Then I separated the ones shaped like credentials. A pattern matching names that end in API_KEY, TOKEN, SECRET, PASSWORD or SALT returns nine. Three more I added by hand although the pattern misses them — TOKEN_ENC_KEY (ends in _KEY), GEMINI_API_KEYS (plural) and YOUTUBE_CLIENT_ID (ends in _ID). So the real rule is "suffix match plus judgement," and the total is 12:

BLUESKY_APP_PASSWORD · TOKEN_ENC_KEY · VISITOR_HASH_SALT · YOUTUBE_CLIENT_ID · YOUTUBE_CLIENT_SECRET · YOUTUBE_REFRESH_TOKEN · CLOUDFLARE_API_TOKEN · MISTRAL_API_KEY · GEMINI_API_KEYS · BURNCPU_API_TOKEN · GH_API_TOKEN · LINKEDIN_ACCESS_TOKEN

These twelve do not carry equal weight. Some make noise when absent: the path that calls Mistral directly says if (!key) throw new Error('MISTRAL_API_KEY missing'), and the Bluesky endpoint throws without its password. Noise is good — you find out. The dangerous ones continue in silence, and there are exactly two: TOKEN_ENC_KEY and VISITOR_HASH_SALT. When either is missing the code falls through to another value — which one, shortly.

One credential, two names

Line 89 of .env.example asks for this:

BLUESKY_PASSWORD=          # → bsky.app → Settings → App Passwords (UYGULAMA şifresi!
                           #   normal hesap şifreniz DEĞİL)
Enter fullscreen mode Exit fullscreen mode

I wrote that comment, and went out of my way to stress "app password." The social posting workflow uses the same name: social-post.yml passes BLUESKY_PASSWORD from GitHub Secrets on line 125, and scripts/social-post.ts reads exactly that name. So far, consistent.

But the site's own sharing endpoint, /api/share-bluesky, is fed from a different file. Here is what src/lib/bluesky.ts does:

const appPassword = process.env.BLUESKY_APP_PASSWORD;
// … (the line between reads the service address)
if (!handle || !appPassword) {
  throw new Error('BLUESKY_HANDLE ve BLUESKY_APP_PASSWORD env vars eksik');
}
Enter fullscreen mode Exit fullscreen mode

Same credential, two names. Worse: the error raised when it is missing names a variable that appears nowhere in the setup file. Someone following the document to the letter puts BLUESKY_PASSWORD in .env, calls that endpoint, and is told to go find BLUESKY_APP_PASSWORD. It is not in the file, not in the guide — it exists only inside the code.

So I checked which one is actually set on the live server: BLUESKY_APP_PASSWORD is set, BLUESKY_PASSWORD is not there at all. That makes sense, because the process on the server serves the site and its API endpoints, while the posting script runs on the CI side with its own secrets. Two names living in two environments, and both of them work today.

If this arrangement works, it is because I typed two different names into two different places by hand. Nothing anywhere records that the two are the same password. It belongs to the same family as the run where I counted seven claims in my own recipe that described the code incorrectly — document and code each correct alone, wrong together.

Three jobs on one token

The second silent line, TOKEN_ENC_KEY, held me longer. The name says what it is for: a key used to encrypt something. It is read in exactly one place — src/lib/secret-box.ts, the module that encrypts LinkedIn's OAuth tokens with AES-256-GCM before they are written to the SQLite database on disk. (The name appears a few more times in that file, but those are the module's own doc comments and one error string. A count that does not separate comment from code gets this wrong too.)

That module's key derivation looks at TOKEN_ENC_KEY first; failing that it derives a key from ADMIN_API_TOKEN via scrypt; if neither exists it throws and stops. That last step is good behaviour — encryption does not quietly proceed with a weak key.

The problem is the middle step. ADMIN_API_TOKEN is already a documented, mandatory key, and as the name suggests it is the admin panel's login token. Six files in the repository read it, but the work they do comes down to three jobs:

Diagram

The first job is the token's declared one: admin-auth.ts guards the panel, while share-to-bluesky.ts, share-to-linkedin.ts and social-post.ts are clients carrying that same token to the /api/share-* endpoints. The by-products of panel login derive from it as well — admin-auth.ts uses HMAC over the same token to produce both the session cookie and the Umami embed key.

The other two jobs grew out of a convenience: we already have a secret value, let us not add another one. I am not guessing at that motive — the justification is written in my own comment at the top of secret-box.ts, which says it works with the existing production environment and requires no new secret.

Here is the state on the server: ADMIN_API_TOKEN is set, TOKEN_ENC_KEY is not, VISITOR_HASH_SALT is not. So the convenience is not a theoretical possibility but today's configuration.

What happens if it is rotated

An admin token is the kind of thing you assume is rotatable. If it leaks, you generate a new one, put it in place and log back into the panel. Is that actually true in this setup?

Rather than reason about it, I called the repository's own module from two separate processes — encrypt with one, decrypt with the other. The values are fake; no real token was involved.

1) encrypt with ADMIN_API_TOKEN=A
   v1:QaSUKyZp/exJJgkE:yIEMn9VKKQ94cuDH1yMmaw==:be/w8SroJUurxBSxzh/5KX1IJ5Hv+ZoJmas+wg==
2) decrypt, same token  → PLAIN: linkedin-sahte-erisim-jetonu
3) decrypt with token B → THROW: Error | Unsupported state or unable to authenticate data
4) no token at all      → THROW: secret-box: TOKEN_ENC_KEY veya ADMIN_API_TOKEN env ayarli olmali
                                  (token sifreleme icin)
5) non-envelope value   → PLAIN: duz-metin-eski-jeton
Enter fullscreen mode Exit fullscreen mode

Line three is the answer. The moment the admin token changes, LinkedIn tokens encrypted under a key derived from the old one become undecryptable. It does not return half the plaintext or garbage — it returns nothing: in AES-GCM, if the authentication tag does not verify, the operation fails. Node's documentation covers the missing-tag and tampered-ciphertext cases explicitly: decipher.final() throws, and the ciphertext should be discarded. The wrong-key case does not appear on that page; I take it from RFC 5116's definition plus line three above. The RFC states that authenticated decryption has a single output, either a plaintext value or a special symbol FAIL indicating the inputs are not authentic — and it defines authenticity relative to the key, so a ciphertext not produced under a given key is not authentic for it.

Line five is the module's other face. A value that is not in envelope form comes back out of decryptSecret unchanged. That is deliberate: plaintext rows written before encryption existed stay readable and get encrypted on their next write. It is an understandable migration convenience, but sitting next to "it does not quietly proceed with a weak key" it deserves naming as a branch that keeps a record readable.

The loss is not permanent either. Re-authorising through /api/linkedin-auth produces a fresh token, and because linkedin.ts calls encryptSecret when storing it, it lands under the current key. So the bill for rotating the token is not data loss: it is one LinkedIn re-authorisation, an update in two places (the single GitHub Secret named ADMIN_API_TOKEN and the env file on the server), every open admin session dropped, and a changed embed key. None of that is a catastrophe — and none of it is what you have in mind while rotating a token.

On the visitor side the effect is the harmless kind. Running the hash for one fixed IP and user agent across four configurations, the digest changed as soon as the token changed. As the code's own comment says: if the salt changes the hashes change, and rate limiting and duplicate detection reset once. Nothing breaks; a counter restarts.

One more detail: if both variables are empty, visitor.ts falls back to a string hardcoded in the repository. On the server that branch is never reached, because ADMIN_API_TOKEN is set — so the privacy property holds, but what holds it up is a dependency declared nowhere.

The OWASP secrets management guidance puts this in two sentences. The diagnosis: services often share the same secrets, which makes identifying the source of a compromise or leak challenging. The prescription: it should be immediately apparent what a secret is used for and where to find it. In my setup, what the token is for is apparent — for its declared job. You learn about its other two jobs by reading three files.

What a fresh install gets

All of this could be dismissed as particular to me. The repository's setup script says otherwise.

The header of .env.example states the file's promise clearly: the values you fill in go to two places, the server's /etc/<project>.env for the running site and GitHub Secrets for the automation. One list, two destinations. And when the target file does not yet exist, setup.sh does exactly that:

sudo cp .env "/etc/$SLUG.env"
Enter fullscreen mode Exit fullscreen mode

So when somebody installs from scratch and follows the guide to the letter, the secrets file on their server is nothing more than the filled-in .env.example (the script only appends a DB_PATH line afterwards). Which means BLUESKY_APP_PASSWORD will not be there, because it is not in the template. Neither will TOKEN_ENC_KEY. On my own server the first one is set — I typed it in later by hand, which is the very inconsistency this piece is about. But TOKEN_ENC_KEY being empty on my server too is not an accident: it is the default output of the installation path.

One list does not stretch across two environments, because the two environments do not want the same things. The server process brings up the site and its API endpoints; the CI run generates and publishes content. One has no need for AI provider keys, the other none for the Turnstile secret. That is where the asymmetry in the count comes from: of the 14 keys on the server, 11 are in the template and three are not — BLUESKY_APP_PASSWORD, BLUESKY_SERVICE and LINKEDIN_REDIRECT_URI. It runs the other way too: four of the template's 29 names are never read under src/ or scripts/ at all, because they are R2 backup keys belonging to the deploy/backup.sh shell script.

So the file is not merely incomplete. It behaves as though there were a single runtime environment, when there are three: the service on the server, the CI run and the backup script.

Giving up on maintaining the document by hand

The lesson I take from this is not "keep your documentation current," because this file was never kept current. git log shows two commits: 28 of its key lines were written in one go on 11 June 2026, and one more was added on 28 September. The repository is not old either — the first commit is 31 March 2026. So the template was written four months ago in a single sitting, and it started falling behind the code that same day. A hand-maintained list that does not live next to the code can only decay.

The rules I now hold myself to:

A credential gets one name. If two environments need two names, one should fall back to the other, and that fallback should be visible on a single line of code. On the GH_API_TOKEN side I got this right already: the ops page checks GITHUB_API_TOKEN and falls back to GH_API_TOKEN, both names sitting side by side in one place. On the Bluesky side I did not, because the two reads live in two different files.

A default is a decision, not a convenience. Every sentence that begins "we already have a secret value" writes an undeclared dependency into the system. If a fallback is warranted, the place where the secret is documented should say so — not the module that consumes it: this token does these three jobs, and rotating it affects all three.

Derive the contract from the code, but do not trust your counter. The two grep commands above take seconds and produce the first two numbers on their own; the third needs the line starts of a file on the running server, which a check step cannot easily reach. And the second section of this piece shows the naive pattern missing at least four names. Any such check has to cover the ?. form and flag dynamic reads separately. As this piece goes live that check is not yet in place — the finding is open, not fixed.

If you want to run the same count on your own repository, the order is: extract the names from the template, extract the names the code reads, compare the two lists with comm, then read only the line starts of the file on the running server — you do not need the values. Then sort the difference: variables the runtime supplies itself are noise, tuning knobs with defaults are preference, anything shaped like a credential is a work list. Mine has 12 lines on it, and two are not the sort of thing you fix in one sitting; adopting TOKEN_ENC_KEY on a live box means setting the key and letting existing rows re-encrypt on their next write.

One of the habits that comes with experience is confusing knowing where a system is written down with knowing that it is written down correctly. The run where I counted sixty-eight backups that nginx read exactly one of involved a similar mistake: the existence of a file was taken as evidence that the file was being read.

A system's configuration contract is not the document describing that contract. It is the set of names the code reads — and seeing that set in full is harder than you would think. In my case it grew from 29 to at least 101, and along the way it tied three jobs to one token.

Official Sources

Top comments (0)