DEV Community

Cover image for How to Safely Store and Encrypt API Credentials in Production
Fu'ad Husnan
Fu'ad Husnan

Posted on

How to Safely Store and Encrypt API Credentials in Production

Every leaked API credential starts the same way: a key that was supposed to stay private ends up somewhere it shouldn't, whether that's a public GitHub repo, a log file, or a Slack message. Learning to store and encrypt API credentials in production correctly is one of the highest-leverage security habits a backend team can build, because a single exposed key can grant an attacker the same access your own services have.

This guide walks through the practical mechanics of credential storage, starting with what not to do, then moving through environment variables, dedicated secrets managers, encryption at rest, and rotation strategies that keep systems running without downtime.

The Problem with Hardcoded Credentials

# Never do this
DATABASE_URL = "postgresql://admin:SuperSecret123@prod-db.internal:5432/app"
STRIPE_API_KEY = "sk_live_51H8x2KJ9..."
Enter fullscreen mode Exit fullscreen mode

Hardcoding credentials directly into source files feels convenient during early development, but it creates a permanent record of the secret in version control history. Even deleting the line in a later commit doesn't remove it, since Git preserves every prior revision unless the history is explicitly rewritten and force-pushed. Automated scanners run by attackers crawl public and leaked private repositories specifically looking for patterns like sk_live_, AKIA, or postgresql:// followed by a password.

The fix isn't complicated, but it requires discipline across the whole team. Credentials need to live outside the codebase entirely, in a system designed to hold them, restrict access to them, and audit who touches them.

Environment Variables: The Baseline

# .env file (never committed to version control)
DATABASE_URL=postgresql://admin:SuperSecret123@prod-db.internal:5432/app
STRIPE_API_KEY=sk_live_51H8x2KJ9...
Enter fullscreen mode Exit fullscreen mode
import os
from dotenv import load_dotenv

load_dotenv()
database_url = os.environ["DATABASE_URL"]
stripe_key = os.environ["STRIPE_API_KEY"]
Enter fullscreen mode Exit fullscreen mode

Environment variables are the minimum viable approach to credential storage. They keep secrets out of source code, and most deployment platforms, including Heroku, Render, and container orchestrators like Kubernetes, offer native support for injecting them at runtime. The .env file itself must be listed in .gitignore from the very first commit, since a single accidental push defeats the entire purpose.

Environment variables have real limits, though. They're visible to any process running under the same user; they show up in crash dumps and debugging tools, and they don't support fine-grained access control or automatic rotation. For a solo developer's side project, environment variables are often sufficient. For a production system handling customer data or payments, they're a starting point, not an ending point.

Secrets Managers for Production Systems

import boto3
import json

def get_secret(secret_name, region="us-east-1"):
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

credentials = get_secret("prod/api-credentials")
stripe_key = credentials["stripe_api_key"]
Enter fullscreen mode Exit fullscreen mode

Dedicated secrets managers such as AWS Secrets Manager, HashiCorp Vault, and Google Secret Manager solve the problems environment variables can't. Every one of these tools encrypts secrets at rest by default, logs every access attempt, and supports IAM-based or policy-based permissions so that only specific services or roles can retrieve specific credentials. A payments microservice can be granted access to the Stripe key without also being able to read the database password for an unrelated service.

HashiCorp Vault takes this further with dynamic secrets, where the database credentials handed to an application are generated on demand and expire automatically after a set lease period.

vault write database/config/production \
    plugin_name=postgresql-database-plugin \
    connection_url="postgresql://{{username}}:{{password}}@prod-db:5432/app" \
    allowed_roles="app-role"

vault read database/creds/app-role
Enter fullscreen mode Exit fullscreen mode

Instead of a single long-lived password shared across every service instance, each application process requests its own short-lived credential. If that credential leaks, it becomes worthless after the lease expires, often within an hour, which dramatically narrows the window an attacker has to exploit it.

Encrypting Credentials at Rest

Secrets managers handle encryption transparently, but there are cases where a team stores encrypted credentials in its own database or configuration store, for example, when building a multi-tenant SaaS product that needs to hold each customer's third-party API keys.

from cryptography.fernet import Fernet

# Generate once, store the key in a KMS or secrets manager, never in code
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)

# Encrypting before storage
plaintext_key = b"customer_api_key_abc123"
encrypted_key = cipher.encrypt(plaintext_key)

# Decrypting when the credential is actually needed
decrypted_key = cipher.decrypt(encrypted_key)
Enter fullscreen mode Exit fullscreen mode

The cryptography library's Fernet implementation provides authenticated symmetric encryption, meaning it detects if the ciphertext has been tampered with, not just whether it can be decrypted. The critical detail most teams get wrong is where the encryption key itself lives. Storing the encryption key in the same database as the encrypted credentials defeats the entire scheme, since anyone with database access gets both the lock and the key.

The correct pattern uses envelope encryption: a cloud key management service such as AWS KMS or Google Cloud KMS holds a master key that never leaves the service, and that master key is used to encrypt a per-record data key, which in turn encrypts the actual credential.

import boto3

kms_client = boto3.client("kms")

def encrypt_credential(plaintext_credential, key_id):
    response = kms_client.encrypt(
        KeyId=key_id,
        Plaintext=plaintext_credential.encode()
    )
    return response["CiphertextBlob"]

def decrypt_credential(ciphertext_blob):
    response = kms_client.decrypt(CiphertextBlob=ciphertext_blob)
    return response["Plaintext"].decode()
Enter fullscreen mode Exit fullscreen mode

With this approach, even a full database dump gives an attacker only ciphertext. Decryption requires calling the KMS API, which is itself gated by IAM permissions and produces an audit trail.

Rotating Credentials Without Downtime

Storing credentials securely solves half the problem. The other half is changing them regularly enough that a compromise, even an undetected one, has a limited shelf life. Manual rotation tends to get postponed indefinitely because it's disruptive, so the more durable fix is to make rotation a scheduled, low-risk event rather than an emergency procedure.

def rotate_database_credential(secrets_client, secret_id):
    new_password = generate_secure_password()
    update_database_user_password(new_password)
    secrets_client.put_secret_value(
        SecretId=secret_id,
        SecretString=json.dumps({"password": new_password})
    )
Enter fullscreen mode Exit fullscreen mode

AWS Secrets Manager and Vault both support automatic rotation on a defined schedule, typically by invoking a Lambda function or a rotation plugin that updates the credential in the target system and the secrets store in the same transaction. The key design principle is supporting two valid credentials briefly during the transition window, so that in-flight requests using the old credential don't fail while new requests pick up the new one. Zero-downtime rotation is what separates a mature credential program from one that just checks a compliance box.

Common Mistakes That Undermine Credential Security

A surprising number of credential leaks happen not because a team skipped secrets management entirely, but because of gaps around the edges of an otherwise reasonable setup. Logging frameworks that print full request objects, including headers with Authorization: Bearer tokens, are a frequent culprit. CI/CD pipelines that echo environment variables into build logs for debugging purposes are another, since those logs are often retained and searchable long after the debugging session ends.

Shared credentials across environments cause similar damage. Using the same API key for staging and production means that a compromised staging environment, which usually has weaker monitoring, hands an attacker direct access to production data. Each environment deserves its own credentials, scoped to its own permissions, so that a breach in one doesn't cascade into the others.

Getting API credential storage right isn't a single decision but a layered set of practices: keeping secrets out of source code, using a dedicated secrets manager instead of bare environment variables where the stakes justify it, applying envelope encryption when credentials must live in application-owned storage, and rotating on a schedule instead of waiting for an incident to force the issue. Teams that treat credential management as ongoing infrastructure work, rather than a one-time setup task, are the ones that avoid becoming the next breach headline. Audit your current credential storage against the practices above, and prioritize fixing whichever gap would cause the most damage if exploited first.

Top comments (0)