RE:NODE

Security13 min read

Environment variables and secrets on an app server

API keys, database passwords and bot tokens do not belong in your repository. Where they go instead, how to rotate one, and the ways they still leak.

Updated

0 readers

The most common way a credential leaks is not an attack. It is a repository that was private when the key went in and public when somebody forked it, and by then the key is in the history rather than in the file, where deleting the line does nothing at all. The second most common way is a log line that printed the whole config object on start-up. Neither of those is clever, and both are prevented by the same habit: the value lives on the server, not in the thing you push.

That habit has a mechanism behind it, and the mechanism has sharp edges. Environment variables are not a vault - they are readable by the process, by anything it spawns, and by anyone with access to the tab that sets them. This post is what they actually are, how to set and read them without guessing, exactly what to do when one has already been committed, and the five places they leak even when you did everything else right.

What an environment variable actually is#

Every process on a Unix system starts with two things handed to it by whatever launched it: its arguments, and its environment. The environment is a plain list of NAME=value pairs. Your program reads it through a standard function, and every child process it spawns inherits a copy.

Four properties follow, and all four matter:

  • They are strings. There are no numbers, booleans or nulls. DEBUG=false is the seven-character string false, which is truthy in most languages if you test it directly. Parse deliberately.
  • They are set before the process starts. Changing one on the Startup tab does nothing until you restart. This surprises people who expect a config reload.
  • They are inherited. A shell script that starts your app passes them on. So does a subprocess you spawn to convert an image. If a dependency shells out to something, that something sees your database password.
  • They are not encrypted. They sit in the process's memory and, on Linux, in /proc for anything running as the same user. Their value is that they stay out of your source tree and out of your logs, not that they are locked in a box.

The alternative most people reach for is a config file in the repository, which fails in exactly the way described above, or a config file outside the repository, which is fine and is roughly what a .env file is. The environment simply removes the file from the equation.

Setting them: the Startup tab and .env files#

On a panel-based host the Startup tab carries the server's variables. Set a name and a value, restart, and your application reads it the way it already does. Nothing is written into your checkout, so the next Git pull cannot clobber it and the next push cannot expose it.

The other route is a .env file uploaded over SFTP and never committed. Both are legitimate; the difference is who can see them and what happens on a redeploy.

WhereRead bySurvives a Git pullGood for
Startup tabAnyone with that panel permissionYesThe canonical place for secrets
.env on the serverAnyone with file or SFTP accessYes, if gitignoredLong or multi-line values
.env in the repositoryEveryone, foreverYesNothing

If you use a .env, the .gitignore entry goes in first, before the file exists:

.gitignore
.env.env.*!.env.examplenode_modules/

Commit .env.example with every key and no values, so the next person - including you in eight months - knows what the application needs. Node has read .env natively since 20.6 with node --env-file=.env server.js, so the dotenv package is optional on a current runtime; Python and Rust projects usually use python-dotenv and dotenvy.

Two formatting rules that cause real bugs. Values containing spaces, # or quotes need quoting, and different parsers disagree about escaping, so keep values simple where you can. Multi-line values - a private key, a service account JSON - do not belong in an environment variable at all; base64-encode them into one line, or upload the file and pass its path in a variable instead.

Reading them without guessing#

Each language has one idiomatic accessor, and each has a footgun about missing values.

Node.js
const url = process.env.DATABASE_URL;          // undefined when unsetconst port = Number(process.env.PORT ?? 3000); // always parse numbersconst debug = process.env.DEBUG === "true";    // never trust the string
Python
import osurl = os.environ["DATABASE_URL"]       # raises KeyError when unset - goodport = int(os.getenv("PORT", "8000"))  # explicit defaultdebug = os.getenv("DEBUG", "").lower() in {"1", "true", "yes"}
Rust
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set");let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8080".into())    .parse().expect("PORT must be a number");

In PHP the accessor is getenv(), and values also appear in $_ENV and $_SERVER depending on how the runtime is configured, which is a reliable source of confusion on web plans - prefer getenv() and test it.

Whatever the language, check the required ones at start-up and exit with a clear message rather than discovering the problem on the first request at two in the morning:

javascript
const required = ["DATABASE_URL", "SESSION_SECRET", "DISCORD_TOKEN"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing environment variables: ${missing.join(", ")}`);  process.exit(1);}

A process that refuses to start is a good outcome. A process that starts with DATABASE_URL undefined and silently writes to a local SQLite file for three days is not.

What belongs in the environment#

Anything that grants access to something, and anything that differs between your laptop and the server.

  • Database credentials. On this panel the database slot generates its own host, user and password, so the job is copying the connection string into a variable rather than inventing one. Never reuse it for a second application.
  • API keys and tokens for anything you call - payment providers, mail senders, object storage, a game's API.
  • Webhook URLs. A Discord or Slack webhook URL is a credential: anyone holding it can post as you, to that channel, forever. They get pasted into public issues more than any other secret.
  • Bot tokens. The single most leaked secret in this industry. A Discord token is a full account; there is no scope on it.
  • Signing and session secrets. The key your framework uses to sign cookies or JWTs. If it leaks, someone can mint a valid session as any user, and rotating it logs everybody out, which is exactly the trade you want available.
  • RCON and admin passwords for game servers. Generated per server here, and worth treating as seriously as an SSH key - RCON safely explains why an exposed RCON port is worse than it looks.
  • Anything you would not print on a billboard, which is the test that covers the cases the list above missed.

What does not belong there: large files, anything you need to change at runtime without a restart, and public configuration like a feature flag that is fine in the repository. Also, do not put secrets in the command line arguments of your start command - arguments are visible in process listings in a way the environment is slightly less so, and they end up in logs.

If it has already been committed#

Rotate it. That is the whole answer, and everything else is housekeeping.

Deleting the line and pushing again does not help. The old commit still contains the value, and it stays reachable by its hash even after a force-push in many hosting setups. Anyone who cloned the repository has the full history on their disk. Automated scrapers watch public repositories and new commits continuously, and they are measured in seconds, not hours.

The sequence, in order:

  1. Issue a new credential at the provider. Do this before revoking the old one where the provider allows both to exist, so you have no gap.
  2. Put the new value on the Startup tab and restart the application.
  3. Revoke the old credential. This is the step people skip because the app is working again. The old value is still valid until you do it.
  4. Check what the old one touched. Provider dashboards show recent usage by key. Look for calls you did not make, and for calls from addresses that are not yours.
  5. Then, optionally, clean the history with git filter-repo or BFG. This is cosmetic. It does not un-leak anything, it breaks every existing clone, and it is not a substitute for step 3.
A secret that has been in a public repository for five minutes is a public secret. Scrapers are faster than you are.

GitHub scans public repositories for well-known credential formats and notifies the provider, which is why a leaked cloud key is sometimes disabled before you notice. Do not rely on it: it covers recognisable patterns from participating providers, and your own signing secret is not one of them. If you find evidence the credential was used, what to do when your server is hacked is the wider procedure.

Rotating without downtime#

Rotation should be boring enough that you do it on a schedule rather than only after an incident. The pattern depends on whether the credential can exist twice.

Two keys at once - most API providers, and the easy case. Create the second key, deploy it, confirm traffic on the new key in the provider's dashboard, delete the first. No downtime, no coordination.

One value only - database passwords, session secrets. Here there is a moment of change, so plan it: change the password on the database, update the variable, restart. On a small application that is a few seconds of connection errors, best done when nobody is looking. For a session secret, accept that everyone is logged out, and say so in advance if it matters.

Shared between services - the worst case, because rotating one breaks the other. The fix is structural: one credential per consumer, so nothing is shared and rotation is always local. Database users are cheap. Create one per application with only the permissions it needs, which is the standard advice in database security checklist and the reason connection pools and limits is easier to reason about when each app has its own user.

After any rotation, take a backup of the database before and after if the credential touched it. Database backups and restores covers doing that without a maintenance window.

The ways they leak anyway#

You put the secret in the environment and it still ended up somewhere public. Five routes, in the order they actually happen:

The build inlined it. Front-end tooling deliberately bakes some environment variables into the JavaScript bundle it ships to browsers. Anything named NEXT_PUBLIC_, VITE_ or REACT_APP_ is public by design, regardless of what it contains. A server-side API key given one of those prefixes so it "would work in the component" is a key printed in every visitor's browser. Check your bundle for the value before you ship: if you can find it with the browser's search, so can anyone.

Something logged the config. console.log(process.env) while debugging, an error handler that serialises the whole request context, a stack trace on a framework's default error page in production. Turn debug output off for production, and if your logging library supports redaction, list your secret keys in it.

It went into a screenshot or a ticket. Support conversations, bug reports, streams. A panel ticket here reaches all staff and attachments on it are private, which is the right channel for a credential you have to share - but treat anything that has been in a chat as rotated regardless.

A subprocess inherited it and told somebody. Crash reporters and error-tracking SDKs collect environment data by default in some configurations. Read what your reporter sends before you enable it.

Someone with panel access read it. Which is not a leak so much as a permission you granted; the next section is about narrowing it.

Who can read them on the panel#

The environment is protected by the account boundary, so the account is where the work is.

  • Two-factor authentication on the owning account, with the recovery codes stored somewhere other than the password manager that holds the password. An account with both lost cannot be recovered - see two-factor on your panel account.
  • Subusers with the permission they need and no more. Roles here are granular enough to grant console without files, or files without billing, and access can be time-boxed and joined through a team rather than handed out individually. Subusers and least privilege has the model; the short version is that a moderator who restarts the server does not need the tab that shows your Stripe key.
  • The per-server activity log, which is the record of who changed what and the first thing to read when a value is not what you set.
  • API keys restricted by address, a session list you can sign out of, and rate limits on login. Use all three; they are free.
  • The database slot's one-use sign-in. Opening phpMyAdmin from the panel uses a token that expires in 60 seconds, so the credential is not sitting in your browser history or in a bookmark.

When somebody leaves the project, the order is: remove the subuser, rotate the SFTP credentials for any server they touched, rotate anything they could read. Doing only the first is the most common half-measure there is. The full account-hardening list lives on the security page, and the deploy-side view of the same problem is in deploy a Node.js app from GitHub.

FAQ#

Are environment variables actually secure?

They are safer than a file in your repository and about as safe as a file on the server. They are not encrypted at rest and they are readable by the process, anything it spawns, and anyone with the panel permission to view the Startup tab. That is enough for almost every small deployment. If you need audited, encrypted, per-request secret delivery, you need a secret manager and a different architecture, and you will know it because someone will have written it in a compliance document.

Is a .env file better or worse than the panel?

Different rather than better. A .env handles long or awkward values and keeps everything in one place your code already reads. The Startup tab keeps the value out of the filesystem entirely, so file-level access does not disclose it, and it survives a mistaken rm in the wrong directory. Many people use the panel for secrets and a committed .env.example for documentation, which is a good default.

I pushed my token to a public repo and deleted it. Am I fine?

No. The value is in the commit history and in every clone that already exists, and public repositories are scraped continuously. Issue a new token, deploy it, revoke the old one, then check the provider's usage logs for calls you did not make. Rewriting the history afterwards is tidy but changes nothing about the exposure.

Why is my variable undefined after I set it?

Environment variables are read when the process starts, so a value set after start-up is not visible until you restart. Other candidates: a typo in the name, which is case-sensitive; a .env that is being loaded after the code that reads it; or a build-time variable being looked for at runtime, which is a different mechanism entirely in bundled front-end code.

Can I use the same database password for two applications?

You can, and it makes every future rotation a coordinated outage. Create a database user per application instead, with only the permissions that application needs. It costs nothing, it limits what one compromised app can reach, and it means rotating a credential affects one process.

Where do game server passwords fit into this?

The same place. RCON passwords, admin passwords and API keys for a game server are set on the Startup tab and generated per server on install here, so they are unique by default. The one difference is that some games write their password into a config file that mods can read, so treat any server where you install third-party code as a server whose secrets are known to that code - keeping a modded server clean is the relevant habit.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000