skip to content

Secrets in Memory

A credential read with os.Getenv is visible in os.Environ, and a struct field holding it prints in full under %+v. Interviewers ask what keeps it out of a log line.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

In Go, how do you tell an unset environment variable from one set to an empty string when loading a credential?

level: juniorimportance: must knowfreq 70%

answer

  1. two functions, one difference
  2. one of them returns a second value
  3. an empty password is not a missing one
  4. the boolean reports presence, not content

basics

~20 s

os.Getenv returns an empty string for both cases, so it cannot tell them apart. os.LookupEnv returns the value plus a boolean that is false only when the variable is absent, letting you fail startup instead of running with an empty credential.

solid answer

~40 s

`os.Getenv("DB_PASSWORD")` returns `""` whether the variable is missing, misspelled, or genuinely set to the empty string, so a typo in a deployment manifest becomes an empty password rather than a crash. `os.LookupEnv` returns `(value, ok)`, and `ok` is false only when the key is absent — I check it at startup, treat an empty value as equally invalid for a credential, and return an error before the process starts serving. For anything sensitive I prefer the value to arrive as a file: `os.ReadFile` of a mounted path gives me a `[]byte` I can trim and zero later, the file's permissions gate access, and the value never appears in `os.Environ()`, which any dependency in the binary can enumerate and which child processes inherit by default.

code

go · 14 lines
go
func loadToken() ([]byte, error) {
	if path, ok := os.LookupEnv("TOKEN_FILE"); ok {
		b, err := os.ReadFile(path)
		if err != nil {
			return nil, err
		}
		return bytes.TrimSpace(b), nil
	}
	v, ok := os.LookupEnv("TOKEN")
	if !ok || v == "" {
		return nil, errors.New("TOKEN is not set")
	}
	return []byte(v), nil
}

go deeper

for a junior

Be ready to name both calls and state the difference in one sentence: os.Getenv gives you only a string, os.LookupEnv also gives you a boolean that is false when the variable is absent. Say that a required credential should stop startup.

for a middle

Explain why the empty-string collapse is dangerous specifically for credentials, and show the startup-validation shape: read once, validate, build a typed config struct, return an error rather than defaulting.

for a senior

Show that you know the environment is process-wide and inheritable — os.Environ is readable by every dependency and children get it by default — and argue for the path-in-env, value-in-file pattern with os.ReadFile.

for a principal

Own the fleet-level call: which delivery mechanism services use, who owns the shared config package that reads it, and how you stop teams from reintroducing raw os.Getenv for secrets.

## The two ways to read one variable Go's `os` package gives you the process environment through a very small surface: - `os.Getenv(key string) string` — the value, or `""` if the key is not present. - `os.LookupEnv(key string) (string, bool)` — the value and a boolean that reports **presence**. - `os.Environ() []string` — a copy of the whole environment as `"KEY=value"` strings. - `os.Setenv`, `os.Unsetenv`, `os.ExpandEnv` — mutation and `$VAR` substitution. `os.Getenv` collapses two very different states into the same result. "The variable does not exist" and "the variable exists and is empty" both come back as `""`. For a feature flag that is harmless. For a credential it is the bug that ends up in a postmortem: a manifest that says `DB_PASSWD` instead of `DB_PASSWORD`, or a secret store that mounted nothing, produces an empty password, and a database or an HMAC verifier that accepts an empty secret will happily let the process start. `os.LookupEnv` is the fix, and it is the one you should reach for by default when the value is required: ```go v, ok := os.LookupEnv("DB_PASSWORD") if !ok { return errors.New("DB_PASSWORD is not set") } ``` For a credential, go one step further and reject an empty value too: a present-but-empty secret is never something you want to run with. The rule of thumb is that configuration is validated **once, at startup, into a typed struct**, and the process refuses to become ready if anything required is missing. Reading `os.Getenv` deep inside a request handler both hides the failure until traffic arrives and scatters the name of the variable across the codebase. ## Why the environment is a poor container for a secret Even when you read it correctly, an environment variable has properties that a file does not: - **Everything in the binary can read it.** `os.Environ()` hands any package — including a transitive dependency you did not audit — a copy of every variable, not just the ones you passed it. A crash reporter or a diagnostic endpoint that dumps the environment dumps your secret with it. - **Children inherit it.** If you start another program with `os/exec` and leave the command's `Env` field nil, the child gets the parent's whole environment. An entrypoint binary that loads credentials and then launches the real application leaks them into that application's environment unless you filter deliberately. - **It is a `string`.** Go strings are immutable; there is no supported way to overwrite the bytes of the value `os.Getenv` returned. Converting with `[]byte(v)` gives you a wipeable copy but leaves the original untouched. - **The operating system keeps its own view.** On Linux the initial environment block is visible through `/proc/<pid>/environ` to a process running as the same user; calling `os.Unsetenv` later changes the copy Go hands out, not that snapshot. ## The mounted-file alternative The same secret delivered as a file changes all four points: ```go b, err := os.ReadFile("/etc/secrets/db-password") if err != nil { return err } b = bytes.TrimSpace(b) // most secret files end with a newline ``` `os.ReadFile` returns a `[]byte`. Access is gated by file permissions rather than by process ancestry, nothing enumerates it the way `os.Environ()` enumerates variables, it is not inherited by child processes, and because it is a byte slice you can overwrite it with the `clear` builtin once you are done with it. The common hybrid — and a good default for a fleet — is to put the *path* in an environment variable and the *value* in the file, so the deployment still configures the process through the environment without the secret ever being one of the values. Whatever the source, two habits matter as much as the read itself: never log the value to "confirm it loaded" (log its length or a fingerprint if you must log anything), and never build the process's identity out of an empty string that arrived by accident.

  • What does os.Environ() give a package you never handed the secret to?
    A copy of the entire environment as `"KEY=value"` strings — every variable, not just the ones that package cares about. Any transitive dependency, crash reporter or debug handler in the binary can call it, so an environment-delivered secret is readable by all of your code and all of your dependencies. A file read with `os.ReadFile` is only reachable by code that knows the path and can pass the file's permission check.
  • The secret arrives as a mounted file instead. What has to happen to the bytes os.ReadFile returns?
    Trim it — most secret files are written with a trailing newline, and `bytes.TrimSpace` avoids a credential that fails for an invisible reason. Then check it is non-empty, keep it as `[]byte` rather than converting to `string` if you intend to zero it later, and treat a read error as a startup failure rather than falling back to an empty value.
  • Why validate the credential at startup rather than at first use?
    Because a missing secret discovered at first use is an error inside a request, often after the process has already reported itself ready and taken traffic. Validating in one place at startup makes the failure a fast, loud, non-serving crash that a deployment system can roll back, and it keeps the variable's name in a single configuration file instead of scattered through handlers.

saying these in an interview costs you the question

  • Says os.Getenv returns an error when the variable is missing
  • Treats an empty string as a usable credential
  • Logs the loaded secret to confirm it was read
  • Assumes environment variables are private to the process
  • Reads os.Getenv inside handlers instead of validating at startup
open as a page

Why can fmt's %+v still print a secret field whose type has a String method?

level: middleimportance: should knowfreq 52%

basics

~20 s

fmt reaches struct fields by reflection and can only call String on a field it can take as an interface value. Unexported fields cannot be, a pointer-receiver String is not in a value's method set, and %#v ignores String entirely.

open as a page

With exec.Cmd, how do you keep a loaded secret out of the child process's environment?

level: seniorimportance: should knowfreq 45%

basics

~20 s

If Cmd.Env is nil the child inherits the parent's entire environment, secrets included. Set Cmd.Env explicitly to only what the child needs — filter a copy from Cmd.Environ() — or pass a file path rather than the value.

open as a page

What Go-side secret-handling convention do you mandate fleet-wide, and what do you leave to teams?

level: principalimportance: should knowfreq 38%

basics

~20 s

Mandate the two things that fail silently otherwise: secrets arrive as file contents read with os.ReadFile rather than as environment values, and every secret is a named type whose String method masks it. Leave storage backends and per-service layout to teams.

open as a page

What does the builtin clear(b) do to a []byte, and which copies of those bytes does it miss?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

clear(b) writes the zero value over every element from index 0 to len(b)-1, leaving len and cap unchanged; on a map it deletes all entries. It reaches only that one array, so copies made by conversions or append survive.

open as a page