Skip to content
Go back

systemd Credentials: Secrets Without Env

By KingPin 12 min read
systemd Credentials: Secrets Without Env
Contents

Your Database Password Is Sitting in a Text File You Forgot About

You wrote Environment=DB_PASSWORD=hunter2 in a unit file at 11 PM, it worked, and you went to bed. Congratulations, that password now lives in a file under /etc/systemd/system, in the unit’s loaded properties, and in the environment of every process the service spawns.

The fix is built into systemd and has been for years: credentials. Put the secret in an encrypted file, point LoadCredentialEncrypted= at it, and the service finds a read-only file in $CREDENTIALS_DIRECTORY. No env var, no plaintext at rest. This post compares the two approaches side by side and shows the commands that are short enough that you’ll actually make the switch.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/systemd-credentials

What’s Wrong With Environment=

Environment variables are the default way to hand config to a service, and they’re fine for LOG_LEVEL=debug. For secrets, the systemd authors themselves say no. The systemd.exec man page states that environment variables are “not suitable for passing secrets”, for three reasons:

  1. Variables set on a unit are exposed to unprivileged clients over D-Bus.
  2. They are generally not understood as data that needs protection.
  3. They propagate down the process tree, including across security boundaries like setuid executables.

You can watch reason one happen. This is a transient unit with a secret in Environment=:

leak.sh
systemd-run --user --unit=demo-bad \
-p Environment=DB_PASSWORD=hunter2 sleep 60
systemctl --user show demo-bad.service -p Environment
# Environment=DB_PASSWORD=hunter2

That’s the user manager. System units leak the same way: as a regular UID 1000 account, systemctl show <unit> -p Environment printed the Environment= line of system services running as root, no sudo needed. Any local account on the box can do the same.

Then there’s the process tree. Every child your service forks inherits the variable. Your app shells out to convert or git, and now those carry your database password too. Crash dumps and debug output that print the environment will print it as well. And EnvironmentFile= doesn’t help: the file keeps the secret in plaintext at rest and the variables still land in the environment.

A limit worth stating plainly: /proc/PID/environ is not world-readable. Reading another user’s process gave me “Permission denied”. The same user and root can read it. So a random local account can’t just grab the secret that way. The leaks are the quieter ones above, and those are harder to audit because there is no single file to point at.

What systemd Credentials Do Instead

A credential is a small named blob (the man page caps the total at 1 MB per unit) that the service manager copies into a private directory just for that service. The service finds the directory in $CREDENTIALS_DIRECTORY. From the man page:

On my box (systemd 262.1) a user-service credential shows up like this:

dr-x------ 2 you you 60 Nov 4 15:02 /run/user/1000/credentials/run-p3007160-i11400625.service
-r-------- 1 you you 7 Nov 4 15:02 /run/user/1000/credentials/run-p3007160-i11400625.service/dbpass

Directory mode 0500, file mode 0400, owned by the service user. Inside a service, systemd-creds list reports each credential as “secure” (backed by unswappable ramfs), “weak” (other memory), or “insecure” (any mode other than 0400). It’s a quick sanity check.

Picture Environment= as a forklift that drops the pallet in the hallway. A credential is the same forklift delivering to a locked room with one key. Same delivery, very different 2 AM.

The Four Directives You Need

The man page lists several ways to feed credentials in. This is the working set.

DirectiveSourceSafe for secrets?
LoadCredential=ID:PATHA plaintext file (or an AF_UNIX socket)Yes, but the file sits in plaintext at rest
LoadCredentialEncrypted=ID:PATHAn encrypted credential fileYes, the recommended one
SetCredentialEncrypted=ID:VALUEEncrypted blob pasted into the unitYes
SetCredential=ID:VALUEA literal value in the unitNo

That last row matters. The man page says of SetCredential=: “Do not use this option for data that is supposed to be secret, as it is accessible to unprivileged processes via IPC.” It’s for user IDs, public keys, and defaults. When I ran systemctl show on a unit with SetCredential= it did not print the value, but the man page warning outranks my one test, so treat it as non-secret.

There’s also ImportCredential=GLOB, which passes through credentials the service manager already has, searching /etc/credstore/, /run/credstore/, /usr/lib/credstore/ and their .encrypted counterparts. Only a single trailing * is allowed as a wildcard. It’s handy for vendor units that want “everything named myapp.*”.

For LoadCredential= and LoadCredentialEncrypted=, if you omit the path, systemd looks in the credstore directories by name. So LoadCredentialEncrypted=dbpass finds /etc/credstore.encrypted/dbpass. A missing credential in that short form isn’t fatal, so check your service starts with the value you expect.

Encrypting a Secret

systemd-creds encrypt takes a plaintext input and writes an encrypted, Base64-encoded output. The man page says it uses AES256-GCM, so you get integrity as well as confidentiality.

Terminal window
printf '%s' 'hunter2' | sudo systemd-creds encrypt --name=dbpass - /etc/myapp/dbpass.cred
sudo systemd-creds decrypt --name=dbpass /etc/myapp/dbpass.cred -
# hunter2

Two things to notice. First, printf '%s' avoids a trailing newline that would become part of your password. Second, the credential name (dbpass) is embedded in the ciphertext. Per the man page, that stops someone renaming an encrypted file and reusing it for a different purpose: decryption fails if the name doesn’t match. Use --name= whenever the output path doesn’t carry the right name.

Which key encrypts it

The --with-key= flag takes host, tpm2, host+tpm2, null, auto, or auto-initrd. The default is auto:

Check whether you have a TPM2 first:

Terminal window
systemd-analyze has-tpm2

(The older systemd-creds has-tpm2 now just tells you it was replaced by that command.)

No TPM2? Fine. The host key still protects the file against casual theft of the file itself: someone who copies your .cred file off the box, or finds it in a backup, can’t read it without credential.secret. Be clear about what that does not cover: root on the running machine can decrypt anything, because the key is on the same disk. The credential protects secrets at rest (backups, copied files), not against a compromised root account.

Avoid --with-key=null. The man page says it provides no confidentiality and no authenticity.

Wiring It Into a Service

Here’s a system unit:

/etc/systemd/system/myapp.service
[Unit]
Description=Demo app using an encrypted credential
[Service]
ExecStart=/opt/myapp/app.sh
DynamicUser=yes
LoadCredentialEncrypted=dbpass:/etc/myapp/dbpass.cred

DynamicUser=yes works nicely here. The service manager reads the credential file as root and drops a copy only the dynamic user can see, so you don’t have to open up file permissions for a UID that doesn’t exist until the service starts.

If you’d rather not keep a separate file, systemd-creds encrypt with -p (long form --pretty) prints a ready-to-paste SetCredentialEncrypted= line. It needs --name= and - as the output:

Terminal window
printf '%s' 'hunter2' | sudo systemd-creds encrypt --name=dbpass -p - -

The man page’s own recipe puts that output in a drop-in like /etc/systemd/system/xyz.service.d/50-password.conf, prefixed with [Service], then runs systemctl daemon-reload. A drop-in keeps the ciphertext out of the main unit file you might paste into a forum thread.

Reading the Secret in Your App

The app reads a file. That’s it. The path is $CREDENTIALS_DIRECTORY/<name>.

app.sh
#!/bin/sh
secret=$(cat "$CREDENTIALS_DIRECTORY/dbpass")
app.py
import os
from pathlib import Path
db_password = (Path(os.environ["CREDENTIALS_DIRECTORY"]) / "dbpass").read_text()

Yes, $CREDENTIALS_DIRECTORY is itself an environment variable, but it holds a path, not a secret. Leaking it tells an attacker nothing they can use without already being the right user.

Apps that only know env vars or config files

Plenty of software wants the secret as a variable or in a config file. You have options, in order of preference:

  1. A *_FILE variable. Many apps (the official Postgres image’s POSTGRES_PASSWORD_FILE, for example) accept a path instead of a value. The %d specifier expands to the credentials directory, so Environment=POSTGRES_PASSWORD_FILE=%d/dbpass works. The man page uses the same trick with Environment=MYCREDPATH=%d/mycred.
  2. ExecStart= interpolation. ${CREDENTIALS_DIRECTORY}/mycred can appear directly in the command line, as in the man page’s ExecStart=cat ${CREDENTIALS_DIRECTORY}/mycred.
  3. A fixed path. For system services only, the man page says you can also use /run/credentials/UNITNAME where the software can’t interpolate anything. It adds that $CREDENTIALS_DIRECTORY is the primary interface because it also works in user services.

The wrapper-script approach (read the file, export it, exec the app) works too, but it puts you right back in the environment, so reach for it last.

User Services Work Too

You don’t need root to try any of this. With systemd-creds encrypt --user, a per-user service manager can do the same thing. The man page notes that credentials for the user manager must be encrypted with systemd-creds encrypt --user, and those for the system manager without it. A user-encrypted credential can’t be decrypted by the system manager or another user’s manager (unless the attacker can gain those privileges), because the UID, username, and machine-id go into the key.

The kit ships demo-user.sh, which runs the whole comparison under systemd-run --user. The core of it:

Terminal window
systemd-creds --user encrypt --name=dbpass db.pass db.cred
systemd-run --user --wait --pipe \
-p LoadCredentialEncrypted=dbpass:$PWD/db.cred \
sh -c 'ls -l "$CREDENTIALS_DIRECTORY"; cat "$CREDENTIALS_DIRECTORY/dbpass"'

On systemd 262.1 the good run shows the 0500 directory and 0400 file from earlier, and env | grep for the password finds nothing. The bad run shows Environment=DB_PASSWORD=hunter2 right in systemctl show, plus the same line from /proc/PID/environ when read as the same user.

Gotchas

The SumGuy Take

If a value would hurt to see in a screenshot, it does not belong in Environment= or EnvironmentFile=. Use LoadCredentialEncrypted= for anything you control, *_FILE plus %d for apps that want a path, and keep Environment= for log levels and feature flags.

The setup cost is two commands and one unit line. The payoff is that your next systemctl show, crash dump, or “can you paste your unit file?” request doesn’t turn into a password rotation. Your 2 AM self will thank you.

Common Questions

Do systemd credentials work in user services?

Yes. The per-user service manager supports the same directives. Encrypt with systemd-creds encrypt --user, which the man page marks as added in version 256, and the credential decrypts only in that user’s manager. In a user service, $CREDENTIALS_DIRECTORY points under /run/user/<uid>/credentials/, and no root access is needed at any step.

Can root read systemd credentials?

Yes. The man page says credential data is accessible only to the unit’s user and the superuser. Root can read the runtime directory, and on the same machine root can also decrypt the file using the host key. Credentials protect secrets at rest and from other users, not from a compromised root.

What happens if I copy an encrypted credential to another machine?

Decryption fails. The credential is encrypted with a key derived from the TPM2 chip, the host secret in /var/lib/systemd/credential.secret, or both, and the new machine has neither. Re-encrypt the plaintext on the target host. TPM2-bound credentials made on systemd 262 or later also cannot be decrypted by older versions.

Is SetCredential= safe for passwords?

No. The systemd.exec man page says SetCredential= values are accessible to unprivileged processes via IPC and should only hold non-secret data such as user IDs or public keys. For a secret, use LoadCredentialEncrypted= or paste an encrypted blob with SetCredentialEncrypted=.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
LLM Semantic Caching: Stop Paying Twice

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts