It’s not unusual for a service to need access to some kind of configuration, or for that configuration to contain passwords that you’d prefer not to leak. The solution is simple: create a configuration file somewhere, make sure that it’s only readable to the user the service runs at, and you’re done. And then you find out about DynamicUser services, which is where the fun begins.

Starting with the default case

Say for the sake of argument we have a very simple service, running as an unprivileged user, that depends on some password to interact with something else. We could provide that password on the command line and doing so could look something like this:

[Unit]
Description=Example service that depends on secrets

[Service]
Type=simple
User=something-unprivileged
ExecStart=/usr/bin/example-service --password=SECRET

Unfortunately this is a bad idea. On most systems, the command line for every running process is available to any user on the system, so finding out this password could be as simple as:

$ ps aux | grep example-service
somethi+     286  0.0  0.0  90076  5508 ?        Ssl  09:46   0:00 /usr/bin/example-service --password=SECRET
hacker     25285  0.0  0.0   6432  2236 pts/0    S+   15:51   0:00 grep example-service

So instead, you create a configuration file in /etc/example-service.conf that sets all the details that you care about:

password = "SECRET"

Then we set this file’s mode to 0600 (only readable by its owner) and then set its owner to something-unprivileged, and then we can adapt our service to read the configuration file instead, ending up with the following:

[Unit]
Description=Example service that depends on secrets

[Service]
Type=simple
User=something-unprivileged
ExecStart=/usr/bin/example-service --config-file=/etc/example-service.conf

This is fine, this is fairly secure, and works well enough. But we can do better.

Why you want to use DynamicUser=yes

In Unix-like systems, it’s become customary to use system users to separate privileges between different services. There’s nothing inherently different about system users, except that by convention they have a user ID < 1000. Of course you need to create a new user whenever you want to use a new service, but this is fairly simple. There just one catch: getting rid of a user is very impractical. Lennart Poettering explains it very well in his introduction to dynamic users but TL;DR: the user ID tends to stick around in every file the service touched.

The dynamic users concept in systemd addresses this shortcoming by having a user that only exists for the runtime of the service. Opting in to that is as simple as setting DynamicUser=yes in your service file. It also opts you into some other security features that are interesting but outside the scope of this article.

[Unit]
Description=Example service that depends on secrets

[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/bin/example-service --config-file=/etc/example-service.conf

As an added bonus you don’t even have to specify the name of the user any more. Whenever possible, a name will be generated based on the name of the service. If that’s not possible for some reason, one is generated using a hash of the service name. All files that are writeable by the service are contained in specific directories and their owner ID is patched shortly before starting up the service with whatever user ID happened to be selected.

You might see the problem coming here: how would we set the owner of the configuration file when the owner doesn’t even exist when the service isn’t running? The answer: we don’t. In the next two sections we will look at two possible workarounds.

Inserting environment variables

If we cannot make the configuration file readable for our application user, we can still make it readable for the root user and provide the secrets to the service in a different way. One way that systemd has supported this is through the EnvironmentFile directive. The trick in use there is that the environment file is read by systemd itself and therefore by root, before being passed as normal environment variables to the service itself. Working that into our service file, we get the following:

[Unit]
Description=Example service that depends on secrets

[Service]
Type=simple
DynamicUser=yes
EnvironmentFile=/etc/example-service.conf
ExecStart=/usr/bin/example-service

The environment file format is slightly different from our example above, so we will need to modify our example-service.conf from above slightly to fit the definition:

password="SECRET"

There, that’s it. There are caveats to using environment variables for your secrets, such as them implicitly being inherited, debug tools often automatically printing them, but in general it’s good enough assuming you can do all the configuration you need via world-readable files and environment variables.

Using systemd credentials

Not all software is flexible enough to deal with environment variables for their configuration in addition to configuration files. Unfortunately, that rules out the method described in the previous section for injecting secrets. So what can we do?

There is a weird kind of hack that you can still pull by copying the configuration into the unit’s state- or runtime directory and changing the owner using ExecStartPre commands. I call them hacks because that’s kind of what they are. But, starting with systemd v247, we no longer have to. The credentials subsystem is here to help.

The credentials subsystem hooks into lots of things, but of interest to us today is the LoadCredential directive. It allows you to mark a file as a credential to be loaded, which will then be put in some place accessible only to the service itself, in-memory only, and locked into RAM to prevent it from swapping out. Putting this into practice, that would look like this:

[Unit]
Description=Example service that depends on secrets

[Service]
Type=simple
DynamicUser=yes
LoadCredential=example-service.conf:/etc/example-service.conf
ExecStart=/usr/bin/example-service --config-file=${CREDENTIALS_DIRECTORY}/example-service.conf

Suddenly we’re almost back at our original service, but now the user the service runs as no longer matters for its access to the secrets at all. The real config file can be readable for root only and it will still work fine.

The credential is specified as [Some identifier]:[Path where it can be found]. The identifier defines where the secret will eventually be stored, relative to some magical credentials directory, and the path is simply the path to copy. This works for regular files, as with our example, but you can create more complex set ups with unix sockets to implement dynamically generated secrets. The credentials directory itself is automatically injected through a variable.

Putting this into production

I think the systemd credentials subsystem has some definite potential to allow easier implementation of dynamic users and related security measures in your service orchestration. And yet I have to be a bit of a downer. The feature was introduced in systemd v247, which was released November 2020. That is very new in terms of systemd features. In fact, when I first learned about this about a week ago the lack of articles about it prompted me to write this one. So it’s not really available in common Linux distributions. For a quick list:

  • Arch Linux: of course you can
  • CentOS: of course you cannot
  • Debian: Bullseye and up
  • Fedora: 34 and up
  • NixOS: You tell me
  • openSUSE: Tumbleweed only right now
  • Ubuntu: Hirsute Hippo and up

If you want some semblance of an LTS release, Debian is your only choice right now. Seeing as the production servers I manage largely run Ubuntu, I’ll be looking forward to Joyful Jellyfish or whatever 22.04 will be called. Until then, I’ll have to contain my excitement.