How to: use custom / self-signed certificates with Requests in Python
In a previous post, I explained how to configure Azurite to use a self-signed certificate to enable OAuth authentication. One challenge with this method is that the Azure Python SDK will refuse to connect to azurite, reporting errors such as:
azure.core.exceptions.ServiceRequestError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)
This is because the the Azure SDK uses the Requests library, which in turn uses Certifi as its source of root certificates. Certifi provides a regularly updated bundle of the Mozilla root trust store, and our self-signed custom certificate obviously isn’t in Mozilla’s trust list!
Making Requests trust our self-signed certificate
You can get around this by setting the REQUESTS_CA_BUNDLE
or CA_BUNDLE
environment variables which tells Certifi to ignore its inbuilt certificate bundles, but if we just point that at our self-signed root certificate then we lose access to the rest of the trust store, which makes connecting to public APIs difficult.
One option is to copy the certifi trust store to a location in our workspace and inject our certificate, then set the environment variables to point to this new store, but that’s more config to manage, and this is just local development – we dont need the extra hassle.
Injecting our certificate in to Certifi’s trust store
So my preferred method is to inject my self-signed certificate in to Certifi’s trust store directly, and I created the following bash script to do this. First it checks you’re in a virtual environment, then checks that certifi is installed (it uses pip for this, but this will work alongside other package managers). Finally, it extracts the first line of the base64 encoded certificate and checks to make sure it isn’t already in the certificate bundle before injecting it along with some metadata to help you figure out where it came from.
#!/bin/bash # Function to log errors and exit log_error() { echo "❌ Error: $1" exit 1 } # Check if custom certificate path is provided CUSTOM_CERT="$1" if [ -z "$CUSTOM_CERT" ]; then log_error "Please provide the path to the custom certificate as an argument." fi # Check if the custom certificate file exists if [ ! -f "$CUSTOM_CERT" ]; then log_error "Custom certificate file not found at $CUSTOM_CERT." fi # Check if in a virtual environment if python -c 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'; then echo "✅ Virtual environment detected: $(python -c 'import sys; sys.stdout.write(sys.prefix)')" else log_error "Not in a virtual environment. Please activate a virtual environment before running the script." fi # Check if certifi is installed if ! python3 -m pip show certifi >/dev/null 2>&1; then log_error "certifi is not installed. Please install it using 'python3 -m pip install certifi'." fi # Get the path to the certifi certificate file CERTIFI_CERT=$(python3 -m certifi) || log_error "Failed to get certifi certificate path." if [ ! -f "$CERTIFI_CERT" ]; then log_error "Certifi certificate file not found at $CERTIFI_CERT." fi # Extract the first line of the public key from the custom certificate as the unique identifier UNIQUE_IDENTIFIER=$(awk '/BEGIN CERTIFICATE/{getline; print}' "$CUSTOM_CERT") echo "Custom certificate: $CUSTOM_CERT" echo "Certifi certificate file: $CERTIFI_CERT" echo "Unique identifier: $UNIQUE_IDENTIFIER" # Check if the custom certificate is already in certifi's certificate file, and append if not if grep -q "$UNIQUE_IDENTIFIER" "$CERTIFI_CERT"; then echo "✅ Custom certificate is already present in certifi's certificate file." else echo "Appending custom certificate..." # Extract additional certificate information ISSUER=$(openssl x509 -in "$CUSTOM_CERT" -noout -issuer | sed 's/issuer=//') SUBJECT=$(openssl x509 -in "$CUSTOM_CERT" -noout -subject | sed 's/subject=//') LABEL=$(openssl x509 -in "$CUSTOM_CERT" -noout -subject | awk -F 'CN=' '{print $2}' | cut -d, -f1) SERIAL=$(openssl x509 -in "$CUSTOM_CERT" -noout -serial | sed 's/serial=//') MD5_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -md5 | sed 's/MD5 Fingerprint=//') SHA1_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -sha1 | sed 's/SHA1 Fingerprint=//') SHA256_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -sha256 | sed 's/SHA256 Fingerprint=//') TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC") # Append the certificate with metadata { echo echo "# Certificate added by script at $TIMESTAMP" echo "# Issuer: $ISSUER" echo "# Subject: $SUBJECT" echo "# Label: \"$LABEL\"" echo "# Serial: $SERIAL" echo "# MD5 Fingerprint: $MD5_FINGERPRINT" echo "# SHA1 Fingerprint: $SHA1_FINGERPRINT" echo "# SHA256 Fingerprint: $SHA256_FINGERPRINT" cat "$CUSTOM_CERT" } >> "$CERTIFI_CERT" || log_error "Failed to append custom certificate to certifi." echo "✅ Custom certificate appended to certifi's certificate file." fi
Drawback – certifi is updated
This approach (or either approach really) has one major drawback – it needs to be run every time Certifi is updated, which is roughly monthly. So, i decided to run the script as a launch task in VSCode so that I can attach it to the “debug” tasks that are already embedded in my workflow. While i was at it, i chained a poetry install
task to ensure that my dependencies are fully installed. I added the task in VSCode’s tasks.json
to run the script after installing the packages (also note i’ve replaced pip
with poetry
in the package task):
{ "version": "2.0.0", "tasks": [ { "label": "func host start", "type": "func", "command": "host start --verbose --script-root ${workspaceFolder}/app", "options": { "env": { "PYTHON_ENABLE_DEBUG_LOGGING": "1" } }, "problemMatcher": "$func-python-watch", "isBackground": true, "dependsOn": "add minica cert to certifi" }, { "label": "add minica cert to certifi", "type": "shell", "osx": { "command": "${workspaceFolder}/azurite/add_minica_cert_to_certifi.sh ${workspaceFolder}/azurite/minica.pem" }, "linux": { "command": "${workspaceFolder}/azurite/add_minica_cert_to_certifi.sh ${workspaceFolder}/azurite/minica.pem" }, "problemMatcher": [], "dependsOn": "pip install (functions)" }, { "label": "pip install (functions)", "type": "shell", "osx": { "command": "poetry install --with dev --no-interaction --sync --no-root" }, "linux": { "command": "poetry install --with dev --no-interaction --sync --no-root" }, "problemMatcher": [] }, ] }
Finally, i added it as a preLaunchTask
in launch.json
so that it runs when i manually debug too:
{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Python: Debug Tests", "type": "debugpy", "request": "launch", "program": "${file}", "purpose": [ "debug-test" ], "console": "integratedTerminal", "justMyCode": true, "stopOnEntry": false, "env": { "PYTEST_ADDOPTS": "--no-cov", "PYTHONPATH": "${workspaceFolder}", "LOG_LEVEL": "DEBUG" }, "preLaunchTask": "add minica cert to certifi" }, { "name": "Python Debugger: Current File", "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", "preLaunchTask": "add minica cert to certifi" }, { "name": "Attach to Python Functions", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 9091 }, "preLaunchTask": "func host start" } ] }
Conclusion
By injecting our self-signed certificate in to Certifi’s trust store, we’ve created a solution that:
- Maintains the integrity of the existing trust store
- Avoids the need for environment variable management
- Integrates seamlessly with our development workflow in VSCode
Although this approach requires us to run the script whenever Certifi is updated, by using a VSCode task configuration we can ensure that this happens automatically, and link it to our existing debug tasks.
Remember, this method is intended for development purposes. In production environments, always use properly signed certificates from trusted certificate authorities.