Python Secrets Management Practice Problems & Exercises
Practice: Secrets Management
← Back to lessonEasy
Read database connection settings from environment variables and return them as a typed dictionary. Why Solution
os.environ.get over direct index access: os.environ['KEY'] raises KeyError if the variable is missing, which crashes the app at startup — often in production when a deployment step forgot to inject a secret. Using .get() with a safe default means the process starts and emits a clear error elsewhere rather than an unhandled exception.
import os
# Simulate environment variables being set
os.environ['DB_HOST'] = 'localhost'
os.environ['DB_PORT'] = '5432'
os.environ['DB_NAME'] = 'myapp'
def load_db_config() -> dict:
# Read DB_HOST, DB_PORT (as int), DB_NAME from env
# Provide sensible defaults if not set
pass
config = load_db_config()
print(f"host={config['host']}")
print(f"port={config['port']}")
print(f"name={config['name']}")Expected Output
host=localhost
port=5432
name=myappHints
Hint 1: Use os.environ.get(key, default) to avoid KeyError when a variable is absent.
Hint 2: Convert port to int with int() — environment variables are always strings.
Hint 3: Never hard-code defaults that look like real credentials — use safe placeholders.
Implement a minimal Real usage with python-dotenv: .env file parser that replicates the core behaviour of python-dotenv.Solution
from dotenv import load_dotenv; load_dotenv() loads .env into os.environ automatically. The .env file must be in .gitignore — it exists only on developer machines and CI, never in version control. Production secrets come from the deployment platform (AWS Secrets Manager, Vault, etc.).
# Simulate what python-dotenv does internally
# (We'll implement a minimal .env parser since we can't write real files here)
ENV_CONTENT = """
API_KEY=sk-your-key-here
REDIS_URL=redis://localhost:6379
DEBUG=false
"""
def parse_dotenv(content: str) -> dict:
# Parse key=value lines, skip comments (#) and blank lines
# Strip surrounding whitespace and optional quotes from values
pass
env = parse_dotenv(ENV_CONTENT)
print(f"API_KEY={env.get('API_KEY')}")
print(f"REDIS_URL={env.get('REDIS_URL')}")
print(f"DEBUG={env.get('DEBUG')}")Expected Output
API_KEY=sk-your-key-here
REDIS_URL=redis://localhost:6379
DEBUG=falseHints
Hint 1: Split each non-blank, non-comment line on the first = only (use split("=", 1)).
Hint 2: Strip leading/trailing whitespace from both key and value.
Hint 3: python-dotenv skips lines starting with # — handle that case first.
Write a Why logs are a prime leak vector: Log aggregators (Datadog, Splunk, ELK) store logs for months. A single safe_log_config function that logs configuration values but redacts secrets.Solution
logger.info(f"Connecting with config: {config}") that exposes a raw config dict can leak credentials to everyone with log-read access — often far more people than those who should see secrets. Always audit log statements in code review.
import logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {'password', 'api_key', 'secret', 'token', 'private_key'}
def safe_log_config(config: dict) -> None:
# Log each key-value pair, but redact values whose keys are in SENSITIVE_KEYS
pass
config = {
'host': 'db.example.com',
'port': 5432,
'password': 'super-secret-password',
'api_key': 'sk-live-abc123',
'timeout': 30,
}
safe_log_config(config)Expected Output
INFO: host=db.example.com
INFO: port=5432
INFO: password=***REDACTED***
INFO: api_key=***REDACTED***
INFO: timeout=30Hints
Hint 1: Check if each key (lowercased) is in SENSITIVE_KEYS before logging.
Hint 2: Replace the actual value with a fixed placeholder like "***REDACTED***".
Hint 3: Partial masking (showing last 4 chars) is fine for debugging — full redaction is safest for logs.
Implement three secure token generators using Python's secrets module.Solution
secrets vs random: random uses a Mersenne Twister PRNG — its output is predictable if an attacker observes enough values. secrets uses os.urandom() (CSPRNG backed by the OS entropy pool) — cryptographically unpredictable. Always use secrets for tokens, session IDs, OTPs, and API keys. Never use random for anything security-related.
import secrets
import string
def generate_url_safe_token(nbytes: int = 32) -> str:
# Generate a URL-safe base64 token using secrets module
pass
def generate_api_key(prefix: str = 'sk') -> str:
# Generate an API key like: sk-<32 hex chars>
pass
def generate_otp(length: int = 6) -> str:
# Generate a numeric OTP of given length (cryptographically secure)
pass
print(f"URL token (32 bytes): {generate_url_safe_token()[:20]}...")
print(f"URL token length >= 43: {len(generate_url_safe_token()) >= 43}")
print(f"API key starts with sk-: {generate_api_key().startswith('sk-')}")
print(f"OTP is 6 digits: {generate_otp().isdigit() and len(generate_otp()) == 6}")Expected Output
URL token (32 bytes): (first 20 chars of base64url token)...
URL token length >= 43: True
API key starts with sk-: True
OTP is 6 digits: TrueHints
Hint 1: secrets.token_urlsafe(nbytes) returns a URL-safe base64 string — 32 bytes gives ~43 chars.
Hint 2: secrets.token_hex(nbytes) returns a hex string — 16 bytes gives 32 hex chars.
Hint 3: For OTP: use secrets.choice() in a loop, or str(secrets.randbelow(10**length)).zfill(length).
Medium
Build a minimal in-memory secret store with TTL expiry and access logging, inspired by HashiCorp Vault. Real Vault: HashiCorp Vault adds lease management, dynamic credential generation (e.g., temporary DB passwords), audit backends, and policy-based access control on top of this basic pattern. The access log here is the minimal version of Vault's audit log, which compliance frameworks like SOC 2 require.Solution
import time
from typing import Optional
class SecretStore:
"""
Minimal in-memory secret store with:
- set/get/delete operations
- TTL (time-to-live) for auto-expiry
- Access tracking (who accessed what, when)
"""
def __init__(self) -> None:
pass
def set(self, key: str, value: str, ttl_seconds: Optional[int] = None) -> None:
"""Store a secret with optional expiry."""
pass
def get(self, key: str, accessor: str = 'system') -> Optional[str]:
"""Retrieve a secret; return None if missing or expired. Log access."""
pass
def delete(self, key: str) -> None:
pass
def access_log(self) -> list[dict]:
pass
store = SecretStore()
store.set('db_password', 'hunter2', ttl_seconds=3600)
store.set('api_key', 'sk-live-abc', ttl_seconds=1) # expires in 1s
val = store.get('db_password', accessor='app-service')
print(f"db_password: {val}")
import time; time.sleep(1.1)
expired = store.get('api_key', accessor='app-service')
print(f"api_key after expiry: {expired}")
print(f"access log entries: {len(store.access_log())}")Expected Output
db_password: hunter2
api_key after expiry: None
access log entries: 2Hints
Hint 1: Store each secret as a dict with keys: value, expires_at (float or None), created_at.
Hint 2: In get(), compare time.time() against expires_at — if expired, return None and optionally delete.
Hint 3: Append a dict with key, accessor, timestamp, and hit/miss to a list for the access log.
Implement a secret store that supports versioning and a grace period, so old credentials remain valid briefly after rotation. Why grace periods matter: When you rotate a database password, there is a window where old connections (connection pool) still hold the previous credential. A zero-grace rotation would break live traffic. Vault's Solution
previous_version_count and AWS Secrets Manager's rotation lambda use the same pattern — keep N old versions valid for a short window while connections drain.
import time
from typing import Optional
class RotatingSecretStore:
"""
Secret store that keeps previous versions during rotation,
allowing a grace period where both old and new are valid.
"""
def __init__(self, grace_seconds: int = 30) -> None:
pass
def set(self, key: str, value: str) -> int:
"""Store a new version of a secret. Returns the new version number."""
pass
def get_current(self, key: str) -> Optional[str]:
"""Return the current (latest) version value."""
pass
def get_version(self, key: str, version: int) -> Optional[str]:
"""Return a specific version (if still within grace period or is current)."""
pass
def versions(self, key: str) -> list[int]:
"""List available version numbers for a key."""
pass
store = RotatingSecretStore(grace_seconds=5)
v1 = store.set('db_password', 'old-password')
print(f"version 1: {v1}")
v2 = store.set('db_password', 'new-password')
print(f"version 2: {v2}")
print(f"current: {store.get_current('db_password')}")
print(f"v1 still valid: {store.get_version('db_password', v1) is not None}")
print(f"versions: {store.versions('db_password')}")Expected Output
version 1: 1
version 2: 2
current: new-password
v1 still valid: True
versions: [1, 2]Hints
Hint 1: Store each secret key as a list of (version, value, created_at) tuples.
Hint 2: get_current returns the entry with the highest version number.
Hint 3: get_version checks if the version is the current one OR was created within grace_seconds.
Write a startup validator that checks all required environment variables are present and raises a single error listing every missing one. Fail-fast configuration: A process that starts without its secrets will fail later — often mid-request, in production, under load. Validating all required env vars at startup (before accepting any traffic) converts a runtime crash into a deployment-time error, which is vastly easier to debug. Libraries like Solution
pydantic-settings provide this pattern with type coercion built in.
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class EnvSpec:
name: str
required: bool = True
default: Optional[str] = None
description: str = ''
REQUIRED_ENV = [
EnvSpec('DATABASE_URL', required=True, description='PostgreSQL connection string'),
EnvSpec('SECRET_KEY', required=True, description='App signing key (min 32 chars)'),
EnvSpec('REDIS_URL', required=False, default='redis://localhost:6379', description='Cache URL'),
EnvSpec('LOG_LEVEL', required=False, default='INFO', description='Logging verbosity'),
]
def validate_env(specs: list[EnvSpec]) -> dict[str, str]:
"""
Validate all required env vars are present.
Collect ALL missing vars before raising (don't stop at first error).
Apply defaults for optional vars.
Raise EnvironmentError listing every missing required var.
"""
pass
# Test: missing DATABASE_URL and SECRET_KEY
os.environ.pop('DATABASE_URL', None)
os.environ.pop('SECRET_KEY', None)
try:
config = validate_env(REQUIRED_ENV)
except EnvironmentError as e:
print(f"Startup failed: {e}")Expected Output
Startup failed: Missing required environment variables: DATABASE_URL (PostgreSQL connection string), SECRET_KEY (App signing key (min 32 chars))Hints
Hint 1: Collect all missing required vars into a list first — report them all at once.
Hint 2: For optional vars with defaults, use os.environ.setdefault(name, default) or os.environ.get.
Hint 3: Raise EnvironmentError (not ValueError) — it is semantically correct for missing env config.
Create a Production usage: Pydantic's Secret class that wraps a sensitive string and prevents it from being accidentally logged or printed.Solution
SecretStr implements exactly this pattern — str(secret) returns **********, secret.get_secret_value() returns the real value. Using SecretStr in your Pydantic models means secrets never appear in model .dict() output, log lines using f"{model}", or Sentry error reports that capture local variables.
from dataclasses import dataclass
@dataclass
class Secret:
"""
A wrapper for sensitive string values that:
- Masks the value in __repr__ and __str__
- Provides .reveal() to get the actual value
- Is never accidentally printed in full
"""
_value: str
def __init__(self, value: str) -> None:
pass
def __repr__(self) -> str:
pass
def __str__(self) -> str:
pass
def reveal(self) -> str:
pass
def __eq__(self, other: object) -> bool:
pass
api_key = Secret('sk-live-super-secret-key-abc123')
print(f"repr: {repr(api_key)}")
print(f"str: {str(api_key)}")
print(f"revealed: {api_key.reveal()}")
print(f"equality works: {api_key == Secret('sk-live-super-secret-key-abc123')}")
print(f"different values: {api_key == Secret('other')}")Expected Output
repr: Secret('***')
str: ***
revealed: sk-live-super-secret-key-abc123
equality works: True
different values: FalseHints
Hint 1: Store the real value in a private attribute (e.g., _value) in __init__.
Hint 2: __repr__ should return Secret("***") — never the real value.
Hint 3: reveal() simply returns self._value — the only intentional exposure point.
Hard
Implement an encrypted secrets file using Fernet symmetric encryption with PBKDF2 key derivation. Production notes: In real systems, store the PBKDF2 salt alongside the ciphertext (not hardcoded). Tools like Solution
sops and age use this pattern: the salt and encrypted content travel together as a single blob. Fernet uses AES-128-CBC with HMAC-SHA256 — it provides authenticated encryption, so tampering is detected before decryption even begins.
from cryptography.fernet import Fernet
import json
import base64
class EncryptedSecretsFile:
"""
Encrypt/decrypt a JSON secrets file using Fernet symmetric encryption.
The key is derived from a passphrase using PBKDF2.
"""
def __init__(self, passphrase: str) -> None:
self._fernet = self._make_fernet(passphrase)
def _make_fernet(self, passphrase: str) -> Fernet:
# Derive a 32-byte key from the passphrase using PBKDF2-HMAC-SHA256
# Use a fixed salt for this exercise (in production, store salt alongside ciphertext)
import hashlib
key_bytes = hashlib.pbkdf2_hmac(
'sha256',
passphrase.encode(),
b'fixed-salt-for-demo', # production: random salt stored with ciphertext
iterations=100_000,
dklen=32,
)
return Fernet(base64.urlsafe_b64encode(key_bytes))
def encrypt(self, secrets: dict) -> bytes:
"""Serialize secrets dict to JSON and encrypt."""
pass
def decrypt(self, ciphertext: bytes) -> dict:
"""Decrypt ciphertext and deserialize back to dict."""
pass
# Usage
store = EncryptedSecretsFile(passphrase='my-master-password')
secrets = {'db_password': 'hunter2', 'api_key': 'sk-live-abc123'}
encrypted = store.encrypt(secrets)
print(f"encrypted type: {type(encrypted).__name__}")
print(f"is bytes: {isinstance(encrypted, bytes)}")
decrypted = store.decrypt(encrypted)
print(f"db_password: {decrypted['db_password']}")
print(f"api_key: {decrypted['api_key']}")
# Wrong key should fail
try:
wrong_store = EncryptedSecretsFile(passphrase='wrong-password')
wrong_store.decrypt(encrypted)
print("should not reach here")
except Exception as e:
print(f"wrong key rejected: True")Expected Output
encrypted type: bytes
is bytes: True
db_password: hunter2
api_key: sk-live-abc123
wrong key rejected: TrueHints
Hint 1: Fernet.encrypt(data) takes bytes — encode your JSON string with .encode("utf-8") first.
Hint 2: Fernet.decrypt(token) returns bytes — decode with .decode("utf-8") then json.loads.
Hint 3: A wrong key raises cryptography.fernet.InvalidToken — catch Exception broadly for the test.
Implement envelope encryption: a Data Encryption Key (DEK) encrypts the payload, and the DEK itself is wrapped by a Key Encryption Key (KEK). Why envelope encryption: The KEK (master key) never leaves KMS — only the tiny encrypted DEK travels with the data. To re-encrypt with a new master key (key rotation), you only re-encrypt the DEK — not the potentially huge payload. SOPS, AWS Secrets Manager, GCP Secret Manager, and Kubernetes Secrets (with KMS plugin) all use this pattern.Solution
from cryptography.fernet import Fernet
import json
import base64
# Envelope encryption: a Data Encryption Key (DEK) encrypts the payload,
# and the DEK itself is encrypted with a Key Encryption Key (KEK).
# This models AWS KMS / GCP KMS / SOPS behaviour.
class EnvelopeEncryption:
def __init__(self, kek: bytes) -> None:
"""kek: Key Encryption Key (Fernet key, simulating a KMS master key)"""
self._kek_fernet = Fernet(kek)
def encrypt(self, payload: dict) -> dict:
"""
1. Generate a random DEK (Fernet key)
2. Encrypt the payload with the DEK
3. Encrypt the DEK with the KEK
4. Return: {encrypted_dek, encrypted_payload}
"""
pass
def decrypt(self, envelope: dict) -> dict:
"""
1. Decrypt the DEK using the KEK
2. Decrypt the payload using the DEK
3. Return the original payload dict
"""
pass
# Simulate a KEK (would be stored in KMS, never locally)
kek = Fernet.generate_key()
engine = EnvelopeEncryption(kek)
secrets = {'db_pass': 's3cret', 'token': 'abc123'}
envelope = engine.encrypt(secrets)
print(f"envelope keys: {sorted(envelope.keys())}")
print(f"encrypted_dek present: {'encrypted_dek' in envelope}")
print(f"encrypted_payload present: {'encrypted_payload' in envelope}")
recovered = engine.decrypt(envelope)
print(f"db_pass: {recovered['db_pass']}")
print(f"token: {recovered['token']}")Expected Output
envelope keys: ['encrypted_dek', 'encrypted_payload']
encrypted_dek present: True
encrypted_payload present: True
db_pass: s3cret
token: abc123Hints
Hint 1: Generate a fresh DEK with Fernet.generate_key() on every encrypt call.
Hint 2: Use one Fernet instance (with DEK) to encrypt the payload, another (with KEK) to encrypt the DEK.
Hint 3: Store both as base64 strings (or raw bytes) in the returned envelope dict.
Build a secret scanner that detects hardcoded credentials in Python source code using a set of regex rules, similar to Production secret scanning: Tools like detect-secrets or truffleHog.Solution
detect-secrets, truffleHog, gitleaks, and GitHub's push protection use entropy analysis on top of regex — strings with Shannon entropy above a threshold are flagged even without known patterns. Integrate scanning into pre-commit hooks and CI so secrets are caught before they ever reach the repo, not after.
import re
from dataclasses import dataclass, field
@dataclass
class SecretFinding:
line_number: int
rule_name: str
matched_text: str
severity: str # 'high' | 'medium' | 'low'
SECRET_RULES = [
# (rule_name, pattern, severity)
('aws_access_key', r'AKIA[0-9A-Z]{16}', 'high'),
('generic_api_key', r'(?i)(api[_-]?key|apikey)s*=s*["'][^"']{8,}["']', 'high'),
('generic_password', r'(?i)(password|passwd|pwd)s*=s*["'][^"']{4,}["']', 'medium'),
('private_key_header',r'-----BEGIN (RSA |EC )?PRIVATE KEY-----', 'high'),
('jwt_token', r'eyJ[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}', 'medium'),
]
def scan_code(source: str) -> list[SecretFinding]:
"""
Scan source code line by line for secret patterns.
Return a list of SecretFinding for every match found.
"""
pass
SAMPLE_CODE = '''
import os
# Good — from environment
db_host = os.environ.get('DB_HOST')
# Bad — hardcoded password
password = "MySecretPass123"
# Bad — AWS key
aws_key = "AKIAIOSFODNN7EXAMPLE"
# Bad — API key
api_key = "sk-live-abcdefghijklmnop"
# OK — placeholder
secret = os.environ['SECRET_KEY']
'''
findings = scan_code(SAMPLE_CODE)
for f in findings:
print(f"Line {f.line_number}: [{f.severity.upper()}] {f.rule_name} — {f.matched_text[:40]}")Expected Output
Line 8: [MEDIUM] generic_password — password = "MySecretPass123"
Line 11: [HIGH] aws_access_key — AKIAIOSFODNN7EXAMPLE
Line 14: [HIGH] generic_api_key — api_key = "sk-live-abcdefghijklmnop"Hints
Hint 1: Iterate over source.splitlines() with enumerate(lines, 1) to get 1-based line numbers.
Hint 2: For each line, check every rule using re.search(pattern, line).
Hint 3: matched_text should be line.strip() — the whole trimmed line, not just the regex match group.
