Have you ever created a Python script that needed to pass secrets to an API or something else in order to work, and wondered how you can keep those secrets… well, secret while still using them as needed? Every programmer should know that storing passwords and other secrets in plain text in a code file is bad practice, but sometimes it doesn’t feel like there is another option that is easy to use and doesn’t take a ton of extra time to implement. I am here today to tell you that there is another option that helps you to secure your secrets while still being able to use them whenever needed without much hassle. I was the first person to use this methodology for scripts in my current team, and it has now become a team standard to use this method for storing, retrieving, and securing secrets in code.
What’s In This Post
What is an “.env” file?
The “.env” file is a configuration file that you can use to store any configuration settings you need for your program, including which environment the script is operating in, as well as a place to store sensitive information such as API keys and IDs. In my own Python projects, I use the “.env” file to store ID values associated with the App Registrations I’m using, but I do not store actual secrets in the file. While it is theoretically safe to store secrets in the “.env” file as long as you encrypt that file, my team has decided we would prefer to pull secrets from an Azure Key Vault when needed instead of storing it in any text file, even if that file is encrypted.
An example of what a “.env” file might contain is:
ENVIROMENT=DEVELOPMENT
API_CLIENT_ID=1234-56789-101112123
API_TENANT_ID=9876-5432-103136546
STORAGE_ACCOUNT=blobFileStorageAccount
The values following the key names can be surrounded by quotation marks or not, it does not matter for this. I prefer to not put quotes around anything because I think it looks cleaner. But doing so or not does not affect how you interact with the values from this file.
There is a lot more information surrounding environment variables and how you can use them with your Python scripts than I can cover here. But if you would like to learn more, I felt like this article covered it well. Using .env Files for Environment Variables in Python Applications by Jake Witcher.
Python Cryptography Module
There is a Python library called “cryptography” that you can install and import into your projects, and this library aims to make the “recipe” layer of encryption easy for normal developers to use. This library also has a layer that it calls the “hazmat” layer, since the functions in that layer require more in-depth knowledge of how cryptography works. If you would like to read more about this library, you can check out the documentation here.
Since I do not claim to be an expert on cryptography in any sense, I chose to stick with the “recipe” layer of the cryptography library, using the standard Fernet
cryptography functions. Starting to work with this symmetric encryption is extremely easy, it only requires the following steps.
- Create a new encryption key:
key = Fernet.generate(key)
- Create a Fernet object using the previously created key, to encrypt and decrypt files:
f = Fernet(key)
- After you have created that object, you can encrypt and decrypt whatever you would like at will. You can encrypt variables within your code, or you can encrypt whole files.
How to Encrypt and Decrypt Files with Fernet
For my own Python coding, I have made it a standard to always include a script called “EnvEncryption.py”, where I have two functions that I call as needed to encrypt and decrypt files. Since I created the file for my very first project, I’ve since just been able to copy and past the script into each new project I write so I don’t have to rewrite the same code over and over. Below are my two cryptography functions.
Encryption Function
from cryptography.fernet import Fernet, InvalidToken
import sys
def encrypt(filename, key):
try:
f = Fernet(key)
with open(filename, "rb") as file:
file_data = file.read()
encrypted_data = f.encrypt(file_data)
with open(filename, "wb") as file:
file.write(encrypted_data)
except InvalidToken:
errorMessage = "Token error has occurred while trying to encrypt the file \"" + filename + "'""
errorLine = sys.exc_info()[2].tb_lineno
print("Exception: " + errorType)
print("Error Message: " + errorMessage)
print("Error line: " + str(errorLine))
What the above code is doing is first creating the Fernet encryption object with f = Fernet(key)
. After creating that object from the key that was passed to the function, it will read the data from the specified file, saving it into the variable file_data
. After getting that data into the variable, that data is then passed through the Fernet encrypt
function using the custom key/Fernet object, and is stored in the encrypted_data
variable. Once the data has been encrypted in that variable, it is then written back to the same file, thus making the file encrypted.
Decryption Function
def decrypt(filename, key):
try:
f = Fernet(key)
with open(filename, "rb") as file:
encrypted_data = file.read()
decrypted_data = f.decrypt(encrypted_data)
with open(filename, "wb") as file:
file.write(decrypted_data)
except InvalidToken:
print("File not encrypted, cannot decrypt")
My decryption function is almost the same as the encryption function, just in reverse. The function takes a file and encryption key as input, then generates the Fernet encryption object from the key. The next step is to read the encrypted data from the file into a variable, then pass that data into the Fernet decrypt
function and save the results to the variable decrypted_data
. The function then opens the file again and writes the newly decrypted data into the file.
Creating the Key for the First Time
As you probably noticed from my code above, both the encrypt and decrypt functions take a “key” variable as required input for encrypting and decrypting the file, yet I didn’t say where that was coming from.
Each time I start a new project that will use this encryption and decryption method, I write a temporary script to generate a key for my functions to use, and then I save that key to an Azure Key Vault so I can access it later and pass the value into the script from a command line when the script is called. So if you were to run the above code as-is without generating your own key first, neither of the functions would work.
Before you begin using the above scripts, you need to run the following code to generate a key for yourself to use for encryption and decryption with your project.
Note: After you encrypt your file/data for the first time with the key you are about to generate, you MUST have the same key available to decrypt the data. If you lose the key, you will not be able to easily decrypt your file (unless you’re great at hacking :D). Make sure you save your key in a safe location for future reference after you encrypt your data.
Note: Direct quote from the documentation: “Generates a fresh fernet key. Keep this some place safe! If you lose it you’ll no longer be able to decrypt messages; if anyone else gains access to it, they’ll be able to decrypt all of your messages, and they’ll also be able forge arbitrary messages that will be authenticated and decrypted.”
key = Fernet.generate_key()
print(key)
That code snippet above will print something that looks like b'tWEVGCX-dvt9v-3MZTSehHMD2-2IpJChgit04QZaAy8='
. The keys that are generated are of type byte
, so when you print out to the console it lets you know the string is bytes by surrounding it with b''
. To save that key somewhere safe, you don’t need the b''
part, just the characters within the quotes.
Now that you have this key, you can use it with the encryption and decryption functions as needed.
How to Access Secrets from Encrypted “.env” File
Once you’ve successfully encrypted your .env file, how are you supposed to use the data you stored within it when you need it? The answer is that you will need to decrypt your file whenever you need to pull data from it, then immediately encrypt the file again after you’ve pulled what you need from it. You want to keep the file decrypted for as little time as possible to prevent any possible sniffing of the data that’s in it. I know a good hacker will be able to get the data if they’re determined, but this model is assuming that you’re not in a high-risk or high-security system. If you are in such a scenario, I strongly suggest that you use stronger safety systems than what I have presented here.
An example of what this decryption/encryption format looks like is the following:
ee.decrypt(envFile, encryptionKey)
# Get the data from the file
ee.encrypt(envFile, encryptionKey)
For more information on how to extract key-value pairs from an .env file, please see next week’s post where I cover how I write my code to do that.
Summary
In this post, I covered all the details you need to know in order to start using an encrypted “.env” to store your environment/processing values that you want to keep more secure. The key to doing it well is to use the standard Python cryptography
library and the Fernet functions. Make sure to always encrypt your file again as soon as possible after decrypting it and pulling the data you need from the file. Happy coding!