Keeping Credentials Secure in PHP
One of the most difficult things in any kind of application (not just web applications) is how to protect "secret" values. These values might be API keys, database passwords or even special bypass codes. Ideally, you're not having to define these directly in the application and can have them loaded from another source.
While a lot of the issues around protecting secrets can be removed by better secret handling, it seems like there's still always a need for some kind of secret value to exist in an application. Using this sort of pattern is, obviously, recommended against. The Common Weakness Enumeration database even has an entry specifically about it: CWE-798. Hard-coding credentials, especially plain-text ones, can be a huge risk if an attacker were able to somehow access the code and read them directly.
So what about PHP?
In PHP applications there's a common pattern to keep configuration values and access details in a .env
file that resides in a place where the PHP application can reach it. Given that this is a common practice, I didn't want to stray too far away from it. I want to provide something useful here that can easily replace this setup and still keep things as simple as possible.
I'm going to go through several methods of credential storage, spend some time talking about what it is and the good and bad about it. They're all going to make use of simple storage methods, either based in the code or in a related flat file (like a .env
). We'll start off with the worst possible method - storing the plain-text credentials in the code.
Putting credentials in the code
It's very easy as a developer to think that since you need the credentials to, say, make a connection via a HTTP client to an API that keeping the credentials as close to this code as possible is the easiest and best solution. There are two big things wrong with this approach:
- These hard-coded credentials will now exist in your version control. If it's on a public GitHub repository, you basically just exposed those credentials to anyone that can clone that repository.
- If an attacker was ever able to access the source code for your application, they would have direct access to the secrets without having to do any kind of decryption or reversing work.
IMPORTANT: If you have creds hard-coded in your files, refactor them out immediately. This is a bad security practice (and is mentions in both CWE-256) and the OWASP Top 10 (as A2) in the Broken Authentication item.
There have been several instances over the past several years, some with web applications and some with other kinds of apps/hardware, where default or hard-coded passwords were their downfall. AVOID this at all costs! There is a very, very very slim use case for having any kind of sensitive information directly in your code. Even then, there are usually other protection methods built in to ensure that those can only be used in a small number of circumstances.
.env
file living in the document root
So, now that we've determined that we need to avoid hard-coded, plain text credentials in your code we need to figure out another way to store them. This is where the popular .env
file comes in. Ever since the more modern age of PHP development has come around (thanks to tools like Composer) many frameworks and libraries have adopted the pattern of using a .env
file to store application-specific settings. Naturally, this meant that eventually secrets and sensitive information found it's way in there.
Having all of this in a separate file is better than having it hard-coded but there are still some issues. If you re-read the title of this section, you might find the issue. Remember, anything that's inside of your document root is directly readable by the outside world. In this case, the choice was made to put the .env
file inside of the document root. That means that I could hit http://mycoolsite.com/.env
and be able to access this file directly.
So, we can strike this one off the list as far as a method for storing anything we need to be protected. There's another similar option, however, that can prevent direct access: moving the .env
file out of the document root.
.env
file living outside of the document root
In this case, we're moving the file up one level so it can't be directly accessed from the web. For example, if your document root is /var/www/mycoolsite/public
then you can move the file up one level at /var/www/mycoolsite/.env
. This makes it so that PHP can still access the file but it can't be reached via the web.
For example, we could use the popular vlucas/phpdotenv package to read the file and automatically import it into the current environment:
<?php
require_once __DIR__.'/../vendor/autoload.php';
$dotenv = new Dotenv\Dotenv(__DIR__.'/../');
$dotenv->load();
?>
In this script, the code is told to load the .env
file from one directory up (__DIR__.'/../'
). It then pulls in the key/value combinations and puts them into the $_ENV
superglobal. This method is better than having the file publicly accessible but there are also some downsides to consider:
- If an attacker is able to upload a PHP file and execute code, they could just print out the
$_ENV
values and have direct access. - The values still exist on disk in plain-text so if a local file include issue is found the file can still be read.
So this method is a step in the right direction, but we still could use something a bit more robust to protect our credentials.
Encrypted credentials
The next step in preventing direct access to the secret values is to use either a method of obfuscation or encryption to protect the value. Since we'll need the plain-text version of the value to actually use it, obfuscation is out of the question. Using encryption we can encrypt the value and then decrypt it when needed.
We're going to build on the previous example and put the values in a .env
file located outside of the document root. In order to help make the encryption/decryption process simpler, we're going to make use of the defuse/php-encryption library.
First, we need to install it and generate a key:
composer require defuse/php-encryption
vendor/bin/generate-defuse-key
This will give a key that contains upper and lowercase letters and numbers and has sufficient entropy to be used for this simple operation. This key will need to be stored where PHP can access it but not someplace in (or even close to) the document root of the application. A common practice is to put it somewhere under /usr/local
in a flat file. This file then needs the permissions and owner/group changed so that PHP can read it.
Once you've set up that file, you can then read and decrypt the values from the .env
file. We'll use the same vlucas/phpdotenv
library to read in the file and then php-encryption
to decrypt it. First the example .env
file:
test=def502003cbef858698bc40b2b8d0ffb6f365f2cef00009047650910941da72372313c7ce3f9d4ce8ba2cd64f6a5a5a330da47151c5c90124fd4e8ea792d40810d8906b8a888b12db78f1cbb0819825447ce685b1c608dfb1f30
test1=def502005c647492189c68d7f5fec781a0e10bdee8865b23f729b080c7bbadd2204005367ea6464d75609ea48be235886cd2f398bf60eaa0a0bb32e2906ab9b9b1f66c58fdd24f054b5311460fdf8770c5d729b3c296cb5d
Then the PHP to decrypt it:
<?php
require_once __DIR__.'/../vendor/autoload.php';
$dotenv = new Dotenv\Dotenv(__DIR__.'/../');
$dotenv->load();
$keyContents = file_get_contents('/usr/local/keyfile`);
$key = \Defuse\Crypto\Key::loadFromAsciiSafeString($keyContents);
$secret = \Defuse\Crypto\Crypto::decrypt($ciphertext, $key);
?>
Our decrypted secret value then ends up in the $secret
variable. Obviously, you wouldn't want to have to copy and paste this code all around so it'd be simpler to wrap it in a helper function or class to make it more self-contained.
This is yet another step in the right direction in protecting our credentials but there's still an issue here that's common to all of these flat-file storage methods: the local file include. If an attacker is able to make your code read and expose file contents, that means it could not only read the encrypted values from the .env
but also the contents of the keyfile
since PHP needs to be able to read that too.
We're running out of options here but let me suggest one more. This method still allows you to store the values in a flat-file but protects them from local file include attacks as PHP doesn't need to be able to access the file they're contained in, only the Apache web server. Let's get started.
The "Apache Pull" Method
In this method, we're going to use some of the same kind of techniques as before (storing the secrets encrypted in a flat-file) but there's a new twist: making use of Apache environment variables to relay those values to PHP.
NOTE: This tutorial shows how to set up an Apache web server but this same approach can probably be performed via Nginx as well.
If you want the quick and easy version, I've already set up this repository with a Docker-based example showing how the environment needs to be configured.
Here are the steps the code and applications will follow:
- A file is created containing the encrypted credentials somewhere on the file system (in this case we're just putting it in
/tmp
) - This file is then sourced in the
/etc/apache2/envvars
file as an additional source pulling in these values as local environment variables. - When Apache starts up it pulls in all of the values from
envvars
and redefines them internally. This includes our special values. - These values are pushed out to PHP via Apache environment variables through a
SetEnv
statement.
The question you may be asking now has to do with those pesky local file include issues. Can't the additional settings still be read by PHP? That's where the last piece of the puzzle comes in: the open_basedir
configuration. With PHP, you can use open_basedir
to set the directories that PHP can interact with and keep it from going outside of those. In this case, we can lock PHP down to just the document root and prevent it from reaching out and getting the file manually.
Before I go on and show how it works, I do want to say one thing - this solution isn't perfect either. If an attacker were able to execute PHP code and read from the $_ENV
superglobal, the key value would still be exposed.
The Secrets
First, we'll set up the secrets and get them sourced correctly. In our /tmp/addl-settings
file, we've defined the key value:
export ENC_KEY=1234567890 // This is just a sample key, obviously
Now we source this file in the envvars
file at the bottom of the file:
. /tmp/addl-settings
When this is set up, Apache will then load the ENC_KEY
value into its internal environment and make it available.
The Apache Config
Next up is the Apache configuration. In this case, we're going to make use of virtual hosts but you could also do this at the base level:
<VirtualHost *:80>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SetEnv ENC_KEY ${ENC_KEY}
</VirtualHost>
In this configuration you can see the special ${}
notion that's used to pull an Apache environment variable in and make it available to the running process. In PHP this means making it a value in $_ENV
.
Open_basedir
The final step of the puzzle is set up the open_basedir protection so we create an open_basedir.ini
file and copy it to the right place for Apache to read it as a PHP ini configuration file:
open_basedir=/var/www/html
With all of this in place, the ENC_KEY
value - our encryption key - is now available to PHP via Apache but cannot be accessed directly as a file.
But wait, there's more!
This setup is great and all but you might be asking yourself "How to I read my encrypted configuration settings now"? Well, with the help of a handy library - psecio/secure_dotenv that takes care of a lot of the processing, it's much simpler. We already have the key we need for encryption and decryption in the environment so we'll just reuse that for these examples. First, install the package using Composer:
composer require psecio/secure_dotenv
Then, in your application, create the new Parser
instance feeding in the environment variable with the path to the key file:
<?php
$envFile = __DIR__.'/.env';
$parser = new \Psecio\SecureDotenv\Parser($_ENV['ENC_KEY'], $envFile);
?>
Reusing the .env
file from the above examples gives us values for test
and test1
which can be extracted from the result of a getContent
call.
<?php
echo 'test1 is: '.$parser->getContent()['test1'];
?>
The library handles the decryption of the value for you behind the scenes (using the same defuse/php-encryption library) and you're left with a plain-text result.
Summary
Securing secrets in PHP applications is an interesting problem to tackle. In the research I did prior to this article, I found that - much like any other security-related topic - there's always more than one way to accomplish a task. PHP makes it even more difficult because of how it interacts with web servers. The PHP scripts and processing need to have at the least read access to every file they need to work with. This makes it very difficult to prevent local file include issues if you're not very careful.
There aren't any 100% secure options for credential storage but this "Apache pull" method I've shared here is one of the simpler methods that doesn't require much more than the technology you're already using. Of course, if you have a more complex environment that's deployed using Chef, Vagrant or other tools, those come with some additional features (like encrypted Chef databags) that can be used for credential handling as well.
Remember, there's no "one size fits all" solution for this. It depends a lot on your environment and the risk requirements for your application. Be sure to sit down and create an accurate threat model of your application before making a decision on how you're going to protect your secrets. This will give you a better overall picture of your needs and what kind (or kinds) of protection you'll need.
Resources
- defuse/php-encryption
- psecio/secure_dotenv
- open_basedir
- vlucas/phpdotenv
- An example of the "Apache pull" protected environment (uses Docker)
- Other credential storage options: https://gist.github.com/maxvt/bb49a6c7243163b8120625fc8ae3f3cd
This was a very helpful article, but I just wanted to bring this up for any people that may end up having this issue: If for some reason the $_ENV superglobal doesn’t hold your env variables you’ve set up, trying using the $_SERVER superglobal instead. For some reason that’s where mine got stored… maybe because I’m working on an older Apache 2.4.6 CentOS ver?
Couldn’t you have two users a read and a write in your env vars that are loaded and use a salt for these stored credentials that is used and destroyed when writing. Then you could update the env vars to use the new salt. I suppose depending on traffic to the site this could be a pretty heavy lift and require a little more thought on how to pull it off access wise. May require php to exec a python script after each read or write to complete the update of the salt and keeps php restricted to it area.
Then even if the attacker got the env vars there already burned and won’t work any longer.
In the “Then the PHP to decrypt it:” section, $cypertext is undefined. I’m assuming it should be something like $_ENV[‘test1’] at that stage.
In the top part of the tutorial, it seems like the vlucas/phpdotenv package reads the .env variables and places them into $_ENV and $_SERVER respectively.
However, the later package psecio/secure_dotenv, while it does parse the encrypted file, does not place the variables into $_ENV & $_SERVER. Thus in order to replicate the functionality of vlucas/phpdotenv it seems like some additional steps are required if we want the encrypted psecio/secure_dotenv to act as a ‘drop-in’ replacement for vlucas/phpdotenv ???
Or perhaps I am missing something?? It may also be that placing the decrypted KEY/VALUE pairs into $_ENV & $_SERVER is a bad thing?? (In other words the reason psecio/secure_dotenv doesn’t directly replicate the functionality vlucas/phpdotenv is because it’s a bad idea to do so??)
Thank you for the tutorial. It’s been very helpful.