Manage Secrets and Config in Python Apps

Khimananda Oli 7 min read Programming and Languages
Manage Secrets and Config in Python Apps

By Khimananda Oli | Last reviewed: August 2026

Hardcoded credentials remain the single most common cause of data breaches in Python applications I audit. To properly manage secrets and config in Python apps, you must decouple sensitive values from source code while maintaining strict type safety and validation at startup. This guide covers the production-grade pattern using pydantic-settings, local development workflows with .env files, and secure integration with cloud secret managers.

How Do You Manage Secrets and Config in Python Apps Using Pydantic-Settings?

The standard library os.getenv() is insufficient for production systems because it returns unvalidated strings and fails silently when variables are missing. In 2026, pydantic-settings (v2.x) is the definitive solution to manage secrets and config in Python apps. It provides automatic environment variable loading, type coercion, nested structure support, and immediate validation failures if required configuration is absent.

Config SourcesEnvironment Vars.env FileSecret ManagerPydantic SettingsValidation & Type CoercionNested Models SupportFail-Fast on Missing VarsPython AppTyped Accesssettings.db_hostsettings.api_key
Configuration flow: multiple sources feed into pydantic-settings for validation before the application accesses typed values

Install the library and create a centralized settings module. This approach ensures that misconfigurations crash the app at startup rather than causing silent failures hours later in production.

# requirements.txt
pydantic-settings==2.8.0
python-dotenv==1.1.0

# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="forbid"  # Reject undefined vars to catch typos
    )

    # Required fields - app fails fast if missing
    database_url: str
    api_secret_key: str
    debug: bool = False
    
    # Nested configuration
    redis_host: str = "localhost"
    redis_port: int = 6379

    @field_validator("database_url")
    @classmethod
    def validate_db_url(cls, v: str) -> str:
        if not v.startswith(("postgresql://", "mysql://")):
            raise ValueError("database_url must be postgresql:// or mysql://")
        return v

# Singleton instance - import this everywhere
settings = AppSettings()

This pattern aligns with the principles discussed in the twelve-factor app revisited, where configuration is strictly separated from code. The extra="forbid" setting is critical: it prevents typos in environment variable names from going unnoticed, a common issue I see during SOC 2 audits.

How Should You Handle Environment Variables and .env Files Safely?

Local development requires convenience without sacrificing security discipline. The .env file bridges this gap but must be handled correctly to avoid accidental credential leakage.

Setting Up Local Development Configuration

  1. Create a .env.example file committed to Git with placeholder values only
  2. Add .env to your .gitignore immediately after project initialization
  3. Copy the example file locally: cp .env.example .env
  4. Populate real secrets in .env (never share this file)
  5. Use pre-commit hooks with tools like gitleaks or detect-secrets to scan staged changes
# .env.example (COMMIT THIS)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
API_SECRET_KEY=change-me-to-a-real-secret
DEBUG=true
REDIS_HOST=localhost
REDIS_PORT=6379

# .gitignore (CRITICAL)
.env
.env.local
.env.*.local
!.env.example

A common mistake is assuming .gitignore retroactively removes tracked files. If you accidentally commit a .env file, run git rm --cached .env immediately and rotate every secret contained within it. For teams working across Nepal and global offices, consider that developers may clone repositories over insecure networks; never assume a leaked secret hasn't been intercepted.

Environment Variable Precedence

Understanding precedence prevents debugging nightmares. pydantic-settings resolves configuration in this order (highest priority first):

  • Constructor arguments passed directly to AppSettings()
  • System environment variables
  • Variables defined in .env file
  • Default values in the model definition

This means production environment variables always override local .env files, which is exactly the behavior you want for safe deployments.

How Do You Integrate Cloud Secret Managers With Python Applications?

For production environments, storing secrets in environment variables alone is insufficient for compliance frameworks like SOC 2 or ISO 27001. Dedicated secret managers provide encryption at rest, access logging, automatic rotation, and fine-grained IAM policies. When you manage secrets and config in Python apps at scale, integrating with AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault becomes mandatory.

Python AppPydantic Custom SourceAWS Secrets Manager1. Init Settings2. GetSecretValue3. Encrypted Secret4. Validated ConfigApp Ready
Runtime secret retrieval: the application fetches and validates secrets from AWS Secrets Manager during initialization

Custom Settings Source for AWS Secrets Manager

pydantic-settings supports custom sources. Here's a production-ready implementation that fetches secrets from AWS Secrets Manager and merges them with environment variables:

# aws_settings_source.py
import json
import boto3
from pydantic_settings import PydanticBaseSettingsSource
from botocore.exceptions import ClientError

class AWSSecretsManagerSource(PydanticBaseSettingsSource):
    def __init__(self, settings_cls, secret_name: str):
        super().__init__(settings_cls)
        self.secret_name = secret_name
        self._secrets_cache = None

    def _load_secrets(self) -> dict:
        if self._secrets_cache is not None:
            return self._secrets_cache
        
        client = boto3.client("secretsmanager")
        try:
            response = client.get_secret_value(SecretId=self.secret_name)
            self._secrets_cache = json.loads(response["SecretString"])
        except ClientError as e:
            raise RuntimeError(f"Failed to load secret {self.secret_name}: {e}")
        
        return self._secrets_cache

    def get_field_value(self, field, field_name):
        secrets = self._load_secrets()
        return secrets.get(field_name), field_name in secrets

    def __call__(self):
        return self._load_secrets()

# Usage in config.py
class ProductionSettings(AppSettings):
    @classmethod
    def settings_customise_sources(cls, settings_cls, init_settings, 
                                    env_settings, dotenv_settings, file_secret_settings):
        return (
            init_settings,
            env_settings,
            AWSSecretsManagerSource(settings_cls, "prod/myapp/secrets"),
            dotenv_settings,
        )

This approach keeps your settings interface identical across environments. Developers use .env locally; production pulls from AWS Secrets Manager transparently. For Kubernetes deployments, see Kubernetes secrets management done right for cluster-specific patterns that complement this application-level strategy.

What Are the Security Risks of Mismanaging Python Configuration?

Understanding failure modes helps you build defenses. These are the most common vulnerabilities I encounter when auditing Python applications:

RiskImpactMitigation
Secrets in Git historyCredential theft, unauthorized access, compliance violationPre-commit scanning, gitleaks, immediate rotation on detection
Logging sensitive valuesSecret exposure in log aggregators, SIEM, backup tapesStructured logging with redaction filters, never log settings.* objects
Missing validationSilent failures, wrong database connections, data corruptionpydantic-settings with extra="forbid" and field validators
Overly permissive IAMLateral movement, secret exfiltration by compromised servicesLeast-privilege policies, separate secrets per environment/service
No secret rotationPersistent access after breach, failed auditsAutomated rotation via Secrets Manager, zero-downtime rotation patterns

Logging is particularly dangerous. Even if you never explicitly log secrets, exception tracebacks can include them. Configure your logging framework to redact known sensitive field names. For comprehensive guidance on safe observability, refer to structured logging best practices.

Validating Secrets Before Use

Never trust that a fetched secret is valid. Add runtime checks for critical credentials:

# Validate database connectivity at startup
from sqlalchemy import create_engine, text

def verify_database_connection(url: str) -> None:
    engine = create_engine(url, pool_pre_ping=True)
    with engine.connect() as conn:
        conn.execute(text("SELECT 1"))
    print("✓ Database connection verified")

# Call during app initialization, before accepting traffic
verify_database_connection(settings.database_url)

This fail-fast approach prevents deploying broken configurations. In my experience managing infrastructure for fintech companies in Nepal handling eSewa and Khalti integrations, startup validation has prevented more incidents than any amount of test coverage.

Manage Secrets and Config in Python Apps: Production Checklist

Securing Python configuration is not optional—it's foundational to trustworthy software. Implement these controls before your next production deployment:

  • Replace all os.getenv() calls with pydantic-settings models
  • Add .env to .gitignore and commit only .env.example
  • Enable extra="forbid" to catch configuration typos immediately
  • Integrate with a cloud secret manager for production workloads
  • Implement pre-commit secret scanning with gitleaks
  • Add startup validation for critical external dependencies
  • Configure log redaction for sensitive field names
  • Document secret rotation procedures and test them quarterly

If your team needs help implementing secure configuration patterns, passing SOC 2 audits, or migrating legacy Python apps to modern secret management, reach out to discuss your specific requirements. Secure configuration is the foundation everything else rests on—get it right first.

Frequently Asked Questions

Use environment variables loaded via python-dotenv for local development and cloud-native secret managers like AWS Secrets Manager or HashiCorp Vault for production. Never hardcode credentials in source code or commit them to version control repositories under any circumstances.

Install python-dotenv and call load_dotenv at application startup to populate os.environ from a .env file. Always add .env to your .gitignore and validate required variables early using pydantic-settings to fail fast on missing configuration values.

Yes, because pydantic-settings provides type validation, default values, and nested config parsing on top of environment loading. It catches misconfigurations at startup rather than runtime and integrates cleanly with FastAPI and other modern Python frameworks in 2026.

No, never store secrets in Git.

Use the hvac library to authenticate via AppRole or Kubernetes service accounts and fetch secrets dynamically at runtime. Configure short-lived tokens and enable audit logging to maintain zero-trust access patterns without embedding long-term credentials in your Python codebase.

Dotenv files lack encryption, access controls, and audit trails suitable for production environments. They also create operational drift when manually copied across servers. Reserve them strictly for local development and use managed secret stores with automatic rotation in deployed systems.

Implement dual-secret support where your app reads both old and new values during transition periods. Use secret manager webhooks or polling to detect rotations and refresh in-memory caches without restarting processes or dropping active connections.

Secrets Manager offers automatic rotation and cross-account sharing but costs more per API call. SSM Parameter Store suits static configs and lower-volume access. Choose based on rotation needs and budget; both integrate via boto3 with identical IAM permission models.

Use pipeline-native secret injection like GitHub Actions secrets or GitLab CI variables instead of .env files. Mask outputs automatically, restrict secret scope to specific jobs, and scan logs with tools like gitleaks to catch accidental exposure before deployment completes.

Follow least privilege by granting only secretsmanager:GetSecretValue on specific resource ARNs.

Define defaults in pydantic-settings models and raise descriptive errors for truly required values during initialization. Avoid silent fallbacks that mask misconfiguration; explicit failures at startup prevent subtle bugs and security issues caused by unintended default behavior in production deployments.

Mount Kubernetes secrets as volumes or env vars but enable encryption at rest and RBAC restrictions. Consider External Secrets Operator to sync from external managers like Vault, avoiding direct kubectl edits and ensuring secrets follow GitOps workflows with proper auditability and rotation support.

Inject mock configurations via pytest fixtures or test-specific .env.test files loaded conditionally. Never use real credentials in tests; instead validate that your config loading logic correctly parses, validates, and fails on malformed inputs using synthetic test data.

Network calls to secret managers add latency, so cache decrypted values in memory with TTLs matching rotation windows. Initialize clients once at startup and avoid per-request fetches; most Python apps see negligible overhead when caching is implemented correctly with background refresh strategies.

Enable cloud provider audit logs for all secret read operations and correlate with application request IDs. Log access attempts locally with structured metadata but never log secret values themselves. Review access patterns regularly to detect anomalies and enforce compliance requirements.