Let’s Start With a Real Problem
Go back through this series and count how many times you’ve seen this comment:
# NOTE: Hardcoded placeholder secret for learning purposes only.
# Chapter 17 covers moving secrets like this into a proper .env file.
Since Chapter 10, every database_url, and since Chapter 14, every jwt_secret_key, has been sitting in plain sight, directly inside config.py — a file that would get committed straight to GitHub the moment you ran git push. If that repository were ever public, or even just shared with the wrong person, your database credentials and the secret key protecting every user’s login session would be exposed instantly. This chapter fixes that, permanently, for every project going forward.
What You’ll Learn
- Why secrets should never live directly in your source code
- How to use
python-dotenvto load configuration from a.envfile - How FastAPI’s recommended approach —
pydantic-settingswithenv_file— builds on top of that same idea, with validation included - How to structure
.env,.env.example, and.gitignorethe way real teams do - Theory + hands-on assignment questions to lock in what you’ve learned
Theory: Why Environment Variables?
An environment variable is a value set outside your code, in the environment your program runs in — your terminal session, your hosting platform’s dashboard, a Docker container’s configuration, and so on. Your Python code can read these values at runtime, without that value ever being written down inside a .py file.
A .env file is simply a convenient way to define a bunch of these variables locally, in one place, formatted like this:
KEY=value
ANOTHER_KEY=another_value
The critical habit that makes this actually work as a security practice: .env is never committed to version control. Instead, teams commit a .env.example file — showing which variables are needed, with placeholder or dummy values — so anyone cloning the project knows exactly what to configure, without ever seeing real secrets.
| File | Committed to Git? | Contains |
|---|---|---|
.env | No — listed in .gitignore | Real secrets and local configuration |
.env.example | Yes | Variable names only, with placeholder values |
Program 1: Reading Configuration with python-dotenv
Program Objective
Build a small, standalone FastAPI project that reads simple configuration values from a .env file using python-dotenv directly — understanding the fundamental mechanism before we let pydantic-settings handle it more elegantly in Program 2.
Step-by-Step Implementation
Step 1: Create the project
uv init dotenv-basics-demo
cd dotenv-basics-demo
uv add fastapi --extra standard
uv add python-dotenv
Step 2: Create the project structure
mkdir -p app
touch app/__init__.py
dotenv-basics-demo/
├── app/
│ ├── __init__.py
│ └── main.py
├── .env
├── .env.example
├── .gitignore
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
.env
APP_NAME=Dotenv Basics Demo
DEBUG=True
MAX_UPLOAD_SIZE_MB=10
.env.example
APP_NAME=Change Me
DEBUG=False
MAX_UPLOAD_SIZE_MB=5
.gitignore
.env
__pycache__/
*.pyc
.venv/
app/main.py
import os
from dotenv import load_dotenv
from fastapi import FastAPI
# Reads the .env file and loads its key-value pairs into the process environment.
load_dotenv()
app_name = os.getenv("APP_NAME", "Default App Name")
debug_mode = os.getenv("DEBUG", "False").lower() == "true"
max_upload_size_mb = int(os.getenv("MAX_UPLOAD_SIZE_MB", "5"))
app = FastAPI(title=app_name)
@app.get("/", tags=["Root"])
def read_root():
return {
"app_name": app_name,
"debug_mode": debug_mode,
"max_upload_size_mb": max_upload_size_mb,
}
Detailed Code Explanation
.envholds the real, local values for this machine — this is the file that’s actually used when the app runs..env.exampleis a template with the same variable names, but harmless placeholder values — this is what gets committed to Git, so a new developer joining the project immediately knows what configuration is expected, without ever seeing real secrets..gitignoreexplicitly excludes.envfrom version control — this is the actual mechanism that prevents secrets from ever reaching GitHub in the first place. Without this line,.envwould be committed like any other file.load_dotenv()reads the.envfile in your project’s root and copies its key-value pairs intoos.environfor the current process — after this line runs,os.getenv(...)can see them as if they were set directly in your terminal.os.getenv("APP_NAME", "Default App Name")— the second argument is a fallback used only if the variable isn’t set at all, which is a helpful safety net during development.- Notice everything from
.envarrives as a plain string — that’s whydebug_modeneeds.lower() == "true"to become a real boolean, andmax_upload_size_mbneedsint(...)to become a real number. This manual type conversion is exactly the tedious, error-prone part that Program 2’s approach eliminates.
Execution & Testing
uv run fastapi dev app/main.py
- Open
http://127.0.0.1:8000/and confirm the response reflects the values from your.envfile, not the defaults hardcoded inmain.py. - Change
DEBUG=TruetoDEBUG=Falsein.env, restart the server, and confirm the response updates accordingly — proving the value genuinely comes from the file, not from Python code. - Temporarily rename
.envto.env.bakand restart the server — confirm the app falls back to the hardcoded defaults ("Default App Name", etc.) without crashing, then rename it back.
Program 2: The FastAPI-Recommended Approach — pydantic-settings + .env
Program Objective
Rebuild the full authentication project from Chapter 16 so that every sensitive or environment-specific value — database URL, JWT secret key, token expiry — is loaded from a .env file through pydantic-settings, with automatic type validation and zero manual os.getenv calls anywhere in the codebase.
Step-by-Step Implementation
Step 1: Create the project
uv init secure-config-demo
cd secure-config-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
💡
pydantic-settingsalready depends onpython-dotenvinternally — you don’t need to add it separately or callload_dotenv()yourself. It reads your.envfile automatically once you tell it where to look.
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
secure-config-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── security.py
│ │ └── dependencies.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── token.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
├── .env
├── .env.example
├── .gitignore
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
.env
APP_NAME=Secure Config Demo
APP_VERSION=1.0.0
DATABASE_URL=sqlite:///./app.db
JWT_SECRET_KEY=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a1
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
💡 The
JWT_SECRET_KEYabove is just a random example. Generate your own withopenssl rand -hex 32in your terminal — never reuse a secret key you’ve seen in a tutorial or example.
.env.example
APP_NAME=Secure Config Demo
APP_VERSION=1.0.0
DATABASE_URL=sqlite:///./app.db
JWT_SECRET_KEY=replace-with-your-own-generated-secret
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
.gitignore
.env
__pycache__/
*.pyc
.venv/
*.db
app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str
app_version: str
database_url: str
jwt_secret_key: str
jwt_algorithm: str
access_token_expire_minutes: int
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid or expired token.") from exc
app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError:
raise credentials_exception
username = payload.get("sub")
if username is None:
raise credentials_exception
user = get_user_by_username(db, username)
if user is None:
raise credentials_exception
return user
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_active: bool
model_config = ConfigDict(from_attributes=True)
app/schemas/token.py
from pydantic import BaseModel
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = get_user_by_username(db, form_data.username)
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password.",
)
access_token = create_access_token(subject=user.username)
return TokenResponse(access_token=access_token)
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.core.dependencies import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)):
return current_user
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import user # noqa: F401
from app.routers import auth, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
- The entire fix lives in
config.py. Compare this to every previous chapter’sSettingsclass: those gave each field a hardcoded default value (database_url: str = "sqlite:///./app.db"). This version gives each field no default at all — just a type (str,int). That’s a deliberate, important change: it forces the app to genuinely load these values from somewhere real, rather than silently falling back to a value baked into your source code. model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")is what tellspydantic-settingsto automatically read your.envfile — noload_dotenv()call needed anywhere, sincepydantic-settingshandles that internally.- Automatic type conversion, for free: notice
access_token_expire_minutes: int— even though every value in a.envfile is technically text,pydantic-settingsautomatically converts"30"into the real integer30. This is the exact tedious manual conversion from Program 1 (int(os.getenv(...))) that this approach eliminates entirely. - Automatic validation, for free: if
.envwere missingJWT_SECRET_KEYentirely, or ifACCESS_TOKEN_EXPIRE_MINUTEScontained something non-numeric like"thirty", your app would fail immediately on startup with a clear Pydantic validation error — not silently limp along with a broken or missing secret. Failing loudly and immediately is exactly the behavior you want for missing configuration, rather than discovering the problem later through a confusing runtime bug. - Every other file in this project —
security.py,database.py, the routers — is completely unchanged from Chapter 16. They already referencedsettings.database_url,settings.jwt_secret_key, and so on; they never cared where those values ultimately came from. This is the same architectural payoff you saw back in Chapter 10 when switching from SQLite to PostgreSQL — good separation of concerns means the fix stays contained to one file.
Execution & Testing
uv run fastapi dev app/main.py
- Confirm the app starts normally and
POST /users/register→POST /auth/login→GET /users/meall work exactly as they did in Chapter 16. - Prove the validation works: comment out the
JWT_SECRET_KEY=...line in.enventirely, and try restarting the server. Confirm it fails immediately with a clear Pydantic error telling you exactly which field is missing — then uncomment the line and confirm it starts normally again. - Prove type conversion works: temporarily set
ACCESS_TOKEN_EXPIRE_MINUTES=not-a-numberin.envand restart — confirm you get a clear validation error explaining that an integer was expected, instead of a confusing failure somewhere deep insidesecurity.pylater on.
Tips & Instructions for Developers
- ✅ Always add
.envto.gitignorebefore your first commit — not after. If a secret is ever committed, even once, treat it as compromised and rotate it, since Git history keeps old commits around indefinitely. - ✅ Always commit a
.env.examplealongside your code — it’s how the next developer (including future you, six months from now) knows what configuration the project actually needs. - ✅ Prefer
pydantic-settingswith typed fields and no defaults for anything genuinely sensitive — let missing configuration fail loudly at startup, not silently at runtime. - ✅ Generate real secret keys with a proper random generator (
openssl rand -hex 32), never a phrase you typed yourself or copied from a tutorial. - ✅ Use different
.envfiles (or different values entirely) for development, staging, and production — never point your local development environment at a real production database. - ❌ Don’t hardcode any default value for a secret field in
Settings— a “convenient” default is exactly how secrets accidentally end up shipped in source code. - ❌ Don’t paste real secrets into chat messages, screenshots, tutorials, or Slack — treat them with the same care as a password, because that’s exactly what they are.
Official FastAPI Documentation References
- Settings and Environment Variables
- Pydantic Settings — Dotenv (.env) Support
- python-dotenv Documentation
- Pydantic Settings Management
Theory Questions
- Why should
.envnever be committed to version control, while.env.exampleshould? - What does
load_dotenv()actually do, mechanically, to your running Python process? - Why does giving a
Settingsfield no default value (instead of a hardcoded fallback) improve your application’s security? - What’s the practical benefit of
pydantic-settingsautomatically converting.envstring values into the correct Python types? - Why is “fail immediately on startup” a better behavior for missing configuration than “start anyway, with a broken value”?
Practical Assignment
- Add an environment flag: Starting from Program 2’s project, add an
environment: strfield (values like"development"or"production") toSettings, read from a newENVIRONMENTvariable in.env, and use it to print a startup message indicating which environment the app thinks it’s running in. - Simulate a leaked secret: Starting from Program 2’s project, imagine your
.envfile was accidentally committed and pushed to a public repository. Write out, step by step, exactly what you would need to do to fully remediate this (hint: it’s more than just deleting the file and committing again). - Multiple environment files: Research (and briefly summarize in your own words) how
pydantic-settingscan be configured to load different.envfiles depending on context — for example,.env.developmentversus.env.production— and note one real-world scenario where this would matter for a team.
Summary
In this chapter, you learned:
- Why hardcoded secrets are a serious, avoidable risk — and finally fixed every “Chapter 17 will cover this” placeholder from earlier in the series
- How
python-dotenvloads configuration from a.envfile into your environment - How
pydantic-settingsbuilds on that same idea, adding automatic type conversion and validation, with zero manualos.getenvcalls - The
.env/.env.example/.gitignorepattern real teams use to keep secrets out of source control while still documenting what configuration is needed
