Keeping secrets off the host's surfaces

Environment leaks and systemd credentials.

Intermediate14 min · lesson 14 of 24

A server holds credentials for the systems it talks to: database passwords, API tokens, keys for object storage. Each one sits somewhere on the host, and each place has its own set of readers. In this lesson you find out who can read a secret in an environment variable, on a command line, in shell history and in a configuration file, then move a service's token into a systemd credential, encrypt it at rest with systemd-creds, and rotate it. The goal is a rule you can apply to any service: the secret reaches only the process that needs it, and nothing else on the host can read it. Linux essentials showed how the environment works; this lesson assumes that.

Who can read a secret, by where it sits

The threat is local. An intruder who gets a shell as any account, or a bug that lets one service read another's files, starts by collecting credentials, because they lead to the next system. The question for each place a secret can sit is how many accounts can read it.

Who can read a secret, by where it sits
Any local account
systemctl show -p Environment
unit settings are public over D-Bus
unit files in /etc/systemd/system
mode 0644 by default
ps, /proc/PID/cmdline
every command line on the host
The service account and root
/proc/PID/environ
owner and root only
child processes
inherit the whole environment
core dumps
a copy of the process memory
Anyone with the disk or a copy
shell history
~/.bash_history
configuration files
as readable as their mode
backups and snapshots
everything above, offline
A secret is as exposed as its most readable copy.

Start with the most common pattern: a token passed to a service with Environment=. The demo service runs as its own account, created with sudo useradd --system --no-create-home --shell /usr/sbin/nologin hard-secrets-app, and gets a placeholder token that way. Its program only starts a helper and waits:

/usr/local/lib/hard-secrets/env-app
#!/bin/sh
# Demo app: starts a helper process and waits. The helper inherits the environment.
sleep infinity &
wait
/etc/systemd/system/hard-secrets-env.service
[Unit]
Description=Demo: a service given its API token through the environment
[Service]
Type=exec
User=hard-secrets-app
# Anti-pattern: every local user can read this value.
Environment=API_TOKEN=LAB-ONLY-not-a-real-token-1111
ExecStart=/usr/local/lib/hard-secrets/env-app

Make the program executable (sudo chmod 755), run sudo systemctl daemon-reload and start the unit. Then check what an ordinary user sees, with no sudo:

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl show -p Environment hard-secrets-env
Environment=API_TOKEN=LAB-ONLY-not-a-real-token-1111
$ ls -l /etc/systemd/system/hard-secrets-env.service grep API_TOKEN /etc/systemd/system/hard-secrets-env.service
-rw-r--r-- 1 root root 275 Sep 27 09:57 /etc/systemd/system/hard-secrets-env.service Environment=API_TOKEN=LAB-ONLY-not-a-real-token-1111

systemctl show reads unit properties from systemd over D-Bus, and Environment= is one of them, available to every local account. The unit file is world-readable too: 0644 is the normal mode for unit files. The systemd.exec(5) manual is explicit that environment variables are not suitable for secrets: they are exposed to unprivileged clients over D-Bus and they propagate down the process tree. The process itself is better protected:

deploy@web01 · Ubuntu 26.04 LTS
$ cat /proc/$(systemctl show -P MainPID hard-secrets-env)/environ
cat: /proc/368654/environ: Permission denied
$ pgrep -a -u hard-secrets-app sudo cat /proc/$(pgrep -u hard-secrets-app -x sleep)/environ | tr '\0' '\n' | grep API_TOKEN
368654 /bin/sh /usr/local/lib/hard-secrets/env-app 368655 sleep infinity API_TOKEN=LAB-ONLY-not-a-real-token-1111

The kernel lets only the process owner and root read /proc/PID/environ. But the service started a helper, sleep, and the helper carries the token too: every program a service runs (a backup tool, a plugin, a shell script) receives a copy, and any of them may log its environment when it fails. A core dump holds the process memory, environment included; on RHEL, systemd-coredump also stores the environment block in the journal entry for the crash (the COREDUMP_ENVIRON= field in systemd-coredump(8)). Moving the value into a root-only file with EnvironmentFile= hides it from systemctl show, but not from the process. Here /etc/hard-secrets/env is a root-only (0600) file holding one line, TOKEN= and a placeholder, started as a transient unit:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-run --quiet --unit=hard-secrets-envfile -p User=nobody -p EnvironmentFile=/etc/hard-secrets/env sleep 300 systemctl show -p Environment -p EnvironmentFiles hard-secrets-envfile sudo cat /proc/$(systemctl show -P MainPID hard-secrets-envfile)/environ | tr "\0" "\n" | grep -c TOKEN
Environment= EnvironmentFiles=/etc/hard-secrets/env (ignore_errors=no) 1

systemctl show now lists only the file's path, while the process environment still holds the token (grep -c counts it without printing it), and so will every child it starts.

Command lines and shell history

A secret passed as an argument is worse. A stand-in sync tool (a script that only sleeps) runs as hard-secrets-app with its password on the command line, the way many tools allow; it was started with sudo systemd-run --unit=hard-secrets-sync -p User=hard-secrets-app /usr/local/lib/hard-secrets/hard-secrets-sync --user=backup --password=...:

deploy@web01 · Ubuntu 26.04 LTS
$ pgrep -a -f hard-secrets-sync
368784 /bin/sh /usr/local/lib/hard-secrets/hard-secrets-sync --user=backup --password=LAB-ONLY-not-a-real-password …
$ findmnt -no OPTIONS /proc
rw,nosuid,nodev,noexec,relatime

deploy sees another account's password without sudo, for as long as the process runs. Every account on the host can do the same, because /proc is mounted without the hidepid option (the mount options show none), which is the Ubuntu and RHEL default. Mounting the host's /proc with hidepid=invisible hides other users' processes from every account, but it also breaks monitoring tools that expect to see every process. systemd's ProtectProc=invisible works the other way round: it hides other users' processes from one service (the sandboxing lesson shows it), which is worth setting, but it does nothing to hide that service's own command line from other accounts. The real fix is at the source: pass the secret in a file (many tools have a --password-file or similar option) or on standard input.

What you type is stored too. bash writes your commands to ~/.bash_history when the shell exits, so a password typed as an argument lands in a plain file in your home directory.

deploy@web01 · Ubuntu 26.04 LTS
$ grep -n HISTCONTROL ~/.bashrc /etc/skel/.bashrc
/home/deploy/.bashrc:13:HISTCONTROL=ignoreboth /etc/skel/.bashrc:13:HISTCONTROL=ignoreboth

Ubuntu's default ~/.bashrc sets HISTCONTROL=ignoreboth: a command that starts with a space is not saved, and neither is a line identical to the one before it. A leading space is a safety net for a command you had to type with a secret in it, not a habit to rely on. Better, let the program prompt for it, or read it into a variable without echo: read -rs TOKEN prints nothing and stores nothing in history.

A file only the application can read

Most applications read their secrets from a configuration file. The rule is owner root, group of the application's account, mode 0640, in a directory with mode 0750. The application can read the file but not change it, so a compromised application cannot rewrite its own configuration, and no other account can read it at all. Create the directory and an empty file (size 0) with the final owner and mode before any secret goes in:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -d -m 0750 -o root -g hard-secrets-app /etc/hard-secrets sudo install -m 0640 -o root -g hard-secrets-app /dev/null /etc/hard-secrets/app.conf
$ sudo ls -ld /etc/hard-secrets /etc/hard-secrets/app.conf
drwxr-x--- 2 root hard-secrets-app 4096 Sep 27 09:57 /etc/hard-secrets -rw-r----- 1 root hard-secrets-app 0 Sep 27 09:57 /etc/hard-secrets/app.conf

Then add the value with sudoedit /etc/hard-secrets/app.conf, which edits a temporary copy and writes it back into the existing file, so the owner and mode stay as they are while the size grows. Verify from each side:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ls -l /etc/hard-secrets/app.conf
-rw-r----- 1 root hard-secrets-app 112 Sep 27 09:57 /etc/hard-secrets/app.conf
$ sudo -u hard-secrets-app grep -c api_token /etc/hard-secrets/app.conf
1
$ cat /etc/hard-secrets/app.conf
cat: /etc/hard-secrets/app.conf: Permission denied
$ sudo -u hard-secrets-app sh -c 'echo debug = true >> /etc/hard-secrets/app.conf'
sh: 1: cannot create /etc/hard-secrets/app.conf: Permission denied

The application account reads the file (grep -c prints a count, not the secret), deploy is refused, and the application cannot append to it. The order matters, and this is why:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo touch /etc/hard-secrets/new.conf sudo ls -l /etc/hard-secrets/new.conf
-rw-r--r-- 1 root root 0 Sep 27 09:57 /etc/hard-secrets/new.conf

A file created the usual way gets mode 0644 from root's umask, readable by everyone until someone remembers the chmod. install -m 0640 -o root -g ... /dev/null FILE creates the empty file with the right owner and mode first. The impact of the control is that anything else that needs the file, such as a monitoring check running as another account, needs its own group membership or its own copy. The rollback is chmod and chown; if the file was ever readable by others while untrusted code ran on the host, rotate the secret as well. The pattern is SecOpsLog advice.

systemd credentials

systemd has a mechanism built for this, and it removes the need for the application account to read any file on disk. With LoadCredential=, systemd, running as root, reads the file when the service starts and places a private, read-only copy in a directory whose path it passes to the service in $CREDENTIALS_DIRECTORY. The source file can stay root:root 0600; create it the same way as app.conf (sudo install -m 0600 -o root -g root /dev/null /etc/hard-secrets/token, then sudoedit), and install the demo program below with mode 755.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ls -l /etc/hard-secrets/token
-rw------- 1 root root 30 Sep 27 09:57 /etc/hard-secrets/token
/etc/systemd/system/hard-secrets-cred.service
[Unit]
Description=Demo: a service that reads its API token from a systemd credential
[Service]
Type=exec
User=hard-secrets-app
# systemd reads the file as root and gives the service a private, read-only
# copy in $CREDENTIALS_DIRECTORY. The account needs no access to the file.
LoadCredential=api-token:/etc/hard-secrets/token
ExecStart=/usr/local/lib/hard-secrets/demo-app
/usr/local/lib/hard-secrets/demo-app
#!/bin/sh
# Demo app: read the API token from the credential systemd provides and log only a
# fingerprint of it, never the token itself.
set -eu
token=$(cat "$CREDENTIALS_DIRECTORY/api-token")
echo "token loaded from $CREDENTIALS_DIRECTORY, sha256 $(printf %s "$token" | sha256sum | cut -c1-12)"
exec sleep infinity

api-token is the credential's name inside the service, and the path after the colon is where systemd reads it. The demo logs a short SHA-256 fingerprint, which lets you tell two token versions apart without ever writing a token to the journal. journalctl -n 1 --grep sha256 shows the latest matching line; with --grep, -n lists the most recent matches newest first.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl daemon-reload sudo systemctl start hard-secrets-cred
$ journalctl -u hard-secrets-cred -n 1 --no-hostname --grep sha256
Sep 27 09:57:08 demo-app[369196]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e
$ systemctl show -p Environment -p LoadCredential hard-secrets-cred
Environment= LoadCredential=[unprintable]

The service found its token, and systemctl show now gives an unprivileged user an empty Environment= and not even the path of the credential. Look at the copy systemd made:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ls -l /run/credentials/hard-secrets-cred.service/
total 4 -r--r-----+ 1 root root 30 Sep 27 09:57 api-token
$ sudo getfacl -p /run/credentials/hard-secrets-cred.service/api-token
# file: /run/credentials/hard-secrets-cred.service/api-token # owner: root # group: root user::r-- user:hard-secrets-app:r-- group::--- mask::r-- other::---
$ ls /run/credentials/hard-secrets-cred.service/
ls: cannot open directory '/run/credentials/hard-secrets-cred.service/': Permission denied
$ findmnt -o TARGET,FSTYPE,OPTIONS /run/credentials/hard-secrets-cred.service
TARGET FSTYPE OPTIONS /run/credentials/hard-secrets-cred.service tmpfs ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap

The copy is owned by root with an ACL entry that grants read access to hard-secrets-app and nobody else, and deploy cannot even list the directory. findmnt shows why it never reaches the disk: each service's credentials live in their own small tmpfs, mounted read-only and with noswap, so the plaintext is not written to swap either. The directory exists only while the service runs, and backups of the disk never see it.

Encrypted at rest, rotated on purpose

The source file is still plaintext on disk, readable by root and present in every backup. systemd-creds encrypt turns it into a ciphertext that systemd decrypts only when the service starts. The key is either derived from the machine's TPM2 chip, which never lets it leave the hardware, or kept in /var/lib/systemd/credential.secret, or both. The lab VM has no TPM:

deploy@web01 · Ubuntu 26.04 LTS
$ systemd-analyze has-tpm2
partial -firmware -driver +system +subsystem -libraries -libtss2-esys.so.0 -libtss2-rc.so.0 -libtss2-mu.so.0

partial with -firmware and -driver means no TPM2 device is present (systemd-analyze(1)), so use the host key explicitly. On hardware with a TPM2, the default --with-key=auto binds the credential to both the TPM2 and the host key.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-creds encrypt --with-key=host --name=api-token /etc/hard-secrets/token /etc/credstore.encrypted/hard-secrets-token
Credential secret file '/var/lib/systemd/credential.secret' is not located on encrypted media, using anyway.
$ sudo ls -l /etc/credstore.encrypted/hard-secrets-token sudo cut -c1-64 /etc/credstore.encrypted/hard-secrets-token
-rw-r--r-- 1 root root 171 Sep 27 09:57 /etc/credstore.encrypted/hard-secrets-token Whxqht+dQJax1aZeCGLxmiAAAAABAAAADAAAABAAAABNJ769bim+MkMAwpAAAAAA Kkf+pHayND9J1vY2r//geEPaH1mAZOasa3Xi6ZNIpsrndZDNk+zql5FGU1tPi732 x7C3Jhwj6F
$ sudo ls -l /var/lib/systemd/credential.secret
-r-------- 1 root root 4112 Sep 27 09:57 /var/lib/systemd/credential.secret

The first use creates the host key, and systemd warns that it is not on encrypted media: anyone who can read the root filesystem offline can read the key, which is the reason to prefer a TPM2, or full-disk encryption as in the disk encryption lesson. The output is Base64 ciphertext; --name=api-token is embedded in it, so the credential cannot be renamed and fed to a different service unnoticed. One more consequence of a key on disk: if you build a golden image and anything in the build encrypts a credential or starts a unit with LoadCredentialEncrypted=, the key is created inside the image, and every clone shares it. A credential encrypted on one host then decrypts on all of them. Delete /var/lib/systemd/credential.secret when you seal the image (as you do with /etc/machine-id and the SSH host keys), let each host create its own, and encrypt credentials on the target host. Now point the unit at the encrypted file, delete the plaintext, and restart:

/etc/systemd/system/hard-secrets-cred.service
[Unit]
Description=Demo: a service that reads its API token from a systemd credential
[Service]
Type=exec
User=hard-secrets-app
# systemd decrypts the credential as root and gives the service a private,
# read-only plaintext copy in $CREDENTIALS_DIRECTORY.
LoadCredentialEncrypted=api-token:/etc/credstore.encrypted/hard-secrets-token
ExecStart=/usr/local/lib/hard-secrets/demo-app
deploy@web01 · Ubuntu 26.04 LTS
$ sudo rm /etc/hard-secrets/token sudo systemctl daemon-reload sudo systemctl restart hard-secrets-cred
$ journalctl -u hard-secrets-cred -n 2 --no-hostname --grep sha256
Sep 27 09:57:08 demo-app[369554]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e Sep 27 09:57:08 demo-app[369196]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e

Same fingerprint on both lines (the newest is on top), so the service received the same token, now from ciphertext. /etc/credstore.encrypted ships with Ubuntu and RHEL as a root-only (0700) directory, and systemd searches it when a credential is named without a path. Credentials are read once, at start. Rotation is therefore two steps: write the new value, then restart the service. Here the new token is a random value that is never displayed; in practice it is the one your provider issued.

deploy@web01 · Ubuntu 26.04 LTS
$ openssl rand -hex 20 | sudo systemd-creds encrypt --with-key=host --name=api-token - /etc/credstore.encrypted/hard-secrets-token sudo systemctl restart hard-secrets-cred
Credential secret file '/var/lib/systemd/credential.secret' is not located on encrypted media, using anyway.
$ journalctl -u hard-secrets-cred -n 3 --no-hostname --grep sha256
Sep 27 09:57:08 demo-app[369649]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b0d45d948cf5 Sep 27 09:57:08 demo-app[369554]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e Sep 27 09:57:08 demo-app[369196]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e

The newest line, at the top, has a new fingerprint. Revoke the old token at the provider as well, since rotation on the host does nothing about a copy someone already took. The name check works as described:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-creds decrypt --name=db-password /etc/credstore.encrypted/hard-secrets-token - | wc -c
Embedded credential name 'api-token' does not match filename 'db-password', refusing. 0

Two consequences for the rest of your tooling. Backups: the ciphertext in /etc/credstore.encrypted is useless without /var/lib/systemd/credential.secret, so a backup that contains both contains the secret. SecOpsLog advice is to exclude the host key from backups and re-encrypt credentials from your secret store after a restore (with a TPM2 the key is not on disk at all). Integrity monitoring: AIDE (the integrity lesson) stores checksums and attributes of files, not their contents, so watching /etc/credstore.encrypted shows you when a credential changed without copying it into a report. Keep its database root-only regardless, since a checksum of a short password can be guessed offline. The rollback for this whole section is systemd-creds decrypt into a root-only file and the LoadCredential= line from before.

Never print a secret to check it
cat, echo and systemctl show put the value on your screen, in your terminal's scrollback and possibly in a session recording. Compare fingerprints (sha256sum) or lengths instead, as the demo does.

On RHEL 10

RHEL 10 ships systemd 257, which supports the same LoadCredential=, LoadCredentialEncrypted= and systemd-creds, with the same /etc/credstore.encrypted directory. The same unit works unchanged with SELinux enforcing:

deploy@rocky10 · Rocky Linux 10.2
$ sudo systemd-creds encrypt --with-key=host --name=api-token /etc/hard-secrets/token /etc/credstore.encrypted/hard-secrets-token sudo rm /etc/hard-secrets/token
Credential secret file '/var/lib/systemd/credential.secret' is not located on encrypted media, using anyway.
$ sudo systemctl daemon-reload sudo systemctl start hard-secrets-cred
$ sudo journalctl -u hard-secrets-cred -n 1 --no-hostname --grep sha256 systemctl show -p Environment -p LoadCredentialEncrypted hard-secrets-cred
Sep 27 09:29:05 demo-app[62750]: token loaded from /run/credentials/hard-secrets-cred.service, sha256 b3f7ea2d9c4e Environment= LoadCredentialEncrypted=[unprintable]
$ getenforce sudo ls -lZ /run/credentials/hard-secrets-cred.service/
Enforcing total 4 -r--r-----+ 1 root root system_u:object_r:init_var_run_t:s0 30 Sep 27 09:29 api-token

Try this

On your Ubuntu lab machine, run sudo systemd-run --unit=try-env -p User=nobody -p Environment=TOKEN=placeholder sleep 300 and, without sudo, find the token with systemctl show. Stop it. Then write a placeholder into a root-only file with install -m 0600 /dev/null and sudoedit, and start sudo systemd-run --unit=try-cred -p User=nobody -p LoadCredential=token:/path/to/file sleep 300. Confirm that systemctl show -p Environment try-cred is empty, that sudo ls -l /run/credentials/try-cred.service/ shows the copy with an ACL for nobody, and that ls on that directory without sudo is refused. Stop the unit and check that the directory is gone.

Takeaway

Give a service its secret through a systemd credential, encrypted with systemd-creds, and read by the service from $CREDENTIALS_DIRECTORY; never through Environment=, an argument, or a file other accounts can read. Rotate by writing the new value and restarting, and verify with fingerprints, never by printing the secret.

Quick check
01A service reads its database password with LoadCredential=db:/etc/app/db-password. A teammate asks why the service account still needs read access to /etc/app/db-password. What do you tell them?
Correct — The source file only has to be readable by the service manager; the service reads the copy under $CREDENTIALS_DIRECTORY, which systemd makes readable for that account alone.
Incorrect — That describes an environment variable holding a path. LoadCredential= copies the data, so the account never opens the source file.
Incorrect — systemd reads credentials as the service manager, before it switches to User= and Group=.
Incorrect — systemd leaves the source file untouched; it makes a separate copy in a per-service memory filesystem.
02You encrypted a token with systemd-creds encrypt --with-key=host and wrote the new value into /etc/credstore.encrypted an hour ago. The service's log still shows the old token's fingerprint. Why?
Incorrect — The journal only holds what the service logged. Credentials are never stored in it.
Incorrect — The host key is created once and reused. A credential that cannot be decrypted is an error; systemd has no older copy to fall back to.
Incorrect — /run/credentials is filled by systemd when the service starts; you never place files there yourself.
Correct — The per-service copy is made at start and stays as it was; rotation on the host is a new value plus a restart.
03Your backup job copies all of /etc and /var/lib from a server whose API token is in /etc/credstore.encrypted, encrypted with the host key. The backup storage is less protected than the server. What is the problem?
Correct — Without a TPM2, the host key is a file; a copy of the ciphertext together with that file is a copy of the token.
Incorrect — The cipher is strong, but the key it uses here is the file under /var/lib/systemd, and the backup has it.
Incorrect — A backup running as root reads 0700 directories like any other; the mode stops other accounts, not root.
Incorrect — Base64 is only the encoding of the ciphertext; decoding it gives encrypted bytes, not the token.

Related