123. Os.Environ
Secrets management with environment variables
123. Os.Environ
🔑 Aryan accidentally committed his API key to Git. He now needs to load it safely from environment variables and validate that all required config values are present before the app starts.
💡 Fun fact: In 2013, the “12-Factor App” methodology — written by engineers at Heroku — codified environment variables as the standard way to configure apps. Factor III: “Store config in the environment.” Today every major cloud platform (AWS, GCP, Azure, Heroku, Vercel) injects secrets via environment variables, never files. The python-dotenv library lets you simulate this locally with a .env file that mirrors production env vars. GitHub reports that over 100,000 API keys are accidentally committed to public repositories every single day.
⚠️ Watch out: os.environ.get('KEY') returns None silently when the key is missing. This is fine for optional config, but fatal for required secrets. The bug surfaces far from the missing variable — your HTTP call gets a 401, or mask_secret(None) crashes with TypeError: object of type 'NoneType' has no len(). Always validate required keys at startup with an explicit check and a clear error message that names the missing variable.
🤔 Think about it: mask_secret should show the beginning of a key (like sk-ant-abc...****) so you can confirm which key is configured without exposing it. The broken code shows the end instead: value[-4:] + '...'. Why is showing the end worse than showing the beginning for an API key? Think about how API keys are structured and what information an attacker would get from each approach.
Learning objectives
- Use os.environ.get() for optional vars and os.environ[] for required ones
- Validate all required environment variables at startup
- Use .env files with python-dotenv for local development
- Always add .env to .gitignore
- Mask secrets before logging them
Key concepts
- os.environ.get(key, default) — safe read with fallback
- os.environ[key] — read required variable (KeyError if missing)
- python-dotenv — load .env files in development
- .gitignore — prevent secrets from being committed
- Startup validation — fail fast on missing config
Try it
Concept detail
Environment Variables in Python
Environment variables keep secrets out of source code. Never commit API keys, passwords, or tokens to Git.
Reading env vars
import os
# Optional var — returns None if not set
debug = os.environ.get('DEBUG')
# Optional with default
level = os.environ.get('LOG_LEVEL', 'INFO')
# Required var — raise if missing
key = os.environ['API_KEY'] # raises KeyError if missing
key = os.environ.get('API_KEY')
if not key:
raise RuntimeError('API_KEY not set').env files with python-dotenv
# .env (NEVER commit this file)
ANTHROPIC_API_KEY=sk-ant-...
DB_PATH=/home/user/.ram_manager/db.sqlitefrom dotenv import load_dotenv
load_dotenv() # loads .env into os.environ# .env.example (commit this — placeholder values)
ANTHROPIC_API_KEY=your-api-key-here
DB_PATH=/path/to/database.sqlite.gitignore
.env
*.key
secrets/Masking secrets in logs
def mask(value: str) -> str:
if not value or len(value) <= 4:
return '****'
return value[:-4] + '****'
# 'sk-ant-abcdefghij1234' → 'sk-ant-abcdefghij****'Solution
import os
def get_api_key() -> str:
"""Get the Anthropic API key from environment, raising if missing."""
key = os.environ.get('ANTHROPIC_API_KEY')
if not key:
raise RuntimeError(
'ANTHROPIC_API_KEY not set — export it or add to .env'
)
return key
def load_config() -> dict:
"""Load all required config from environment variables."""
config = {
'api_key': os.environ.get('ANTHROPIC_API_KEY'),
'model': os.environ.get('LLM_MODEL', 'claude-3-haiku-20240307'),
'db_path': os.environ.get('DB_PATH'),
'log_level': os.environ.get('LOG_LEVEL', 'INFO'),
}
required = ['api_key', 'db_path']
missing = [k for k in required if not config[k]]
if missing:
raise RuntimeError(
f"Missing required environment variables: {', '.join(missing)}"
)
return config
def mask_secret(value: str) -> str:
"""Mask the last 4 chars of a secret for safe logging."""
if value is None:
return '****'
if len(value) <= 4:
return '****'
return value[:-4] + '****'Tests
import os
def test_get_api_key_returns_value():
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test123'
try:
result = get_api_key()
assert result == 'sk-ant-test123'
finally:
del os.environ['ANTHROPIC_API_KEY']
def test_get_api_key_raises_when_missing():
os.environ.pop('ANTHROPIC_API_KEY', None)
try:
get_api_key()
assert False, 'Should raise RuntimeError'
except RuntimeError as e:
assert 'ANTHROPIC_API_KEY' in str(e)
def test_load_config_succeeds_with_all_required():
os.environ['ANTHROPIC_API_KEY'] = 'sk-test'
os.environ['DB_PATH'] = '/tmp/test.db'
try:
config = load_config()
assert config['api_key'] == 'sk-test'
assert config['db_path'] == '/tmp/test.db'
assert config['model'] == 'claude-3-haiku-20240307' # default
finally:
del os.environ['ANTHROPIC_API_KEY']
del os.environ['DB_PATH']
def test_load_config_raises_on_missing_required():
os.environ.pop('ANTHROPIC_API_KEY', None)
os.environ.pop('DB_PATH', None)
try:
load_config()
assert False, 'Should raise RuntimeError'
except RuntimeError as e:
assert 'api_key' in str(e) or 'db_path' in str(e)
def test_mask_secret_hides_end():
result = mask_secret('sk-ant-abcdefghijklmnop1234')
assert result.endswith('****'), 'Last 4 chars should be masked with ****'
assert not result.startswith('****'), 'Start of the secret should be visible'
def test_mask_secret_short_value():
assert mask_secret('abc') == '****'
def test_mask_secret_exactly_four_chars():
assert mask_secret('abcd') == '****'
def test_mask_secret_none():
result = mask_secret(None)
assert result == '****', 'mask_secret(None) must not crash'