Home / Courses / Security for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 6 of 8

Hands-On: Secrets, HTTPS & Security Headers

Closing off Security Misconfiguration — secrets management with a fail-secure principle, why TLS lives at the reverse proxy, locking down CORS, and adding cheap but effective HTTP security headers.

July 10, 2026

Our application logic is already secure (Parts 35). Now we close off the last threat that most often slips through: Security Misconfiguration — a mistake not in the code, but in how the application is configured and deployed.

Secrets Management — Never Trust a Default

Look back at our config.py:

class Settings(BaseSettings):
    ...
    secret_key: str = "CHANGE-ME-INSECURE-DEFAULT-DO-NOT-USE-IN-PRODUCTION"

The default is DELIBERATELY made ugly and obvious. If someone forgets to set SECRET_KEY via an environment variable, and the app still runs on this default, the consequences are severe: ANYONE who knows our code (which might be open source, or leaked) can forge a FAKE JWT token that the server will accept as valid — because the signature is computed from a secret key everyone already knows.

We take this one step further in docker-compose.yml, with syntax that FORCES SECRET_KEY to be set:

api:
  environment:
    SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set, don't use the default}

The ${VAR:?error message} syntax in Docker Compose means: if SECRET_KEY isn't present in the host environment, FAIL startup entirely, don't silently continue with an empty value. Prove it directly:

docker compose up -d
error while interpolating services.api.environment.SECRET_KEY:
required variable SECRET_KEY is missing a value:
SECRET_KEY must be set, don't use the default

The application refuses to start at all — instead of starting silently insecure. This is the fail secure principle: if there's a configuration mistake, the system should fail in a SAFE way (stop entirely, make it obvious something's wrong), not fail in a SILENTLY DANGEROUS way (keep running with a weak configuration).

export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
docker compose up -d
# now it runs normally

Another equally important rule: secret_key, a DATABASE_URL containing a password, and other credentials are NEVER written directly in code or committed to git — not even in a .env file (our .gitignore already excludes .env). If you've already followed the CI/CD for Beginners series, this is the exact same principle — secrets live in GitHub Secrets or a dedicated vault, not in the repository.

HTTPS/TLS — Protecting Data While It's In Transit

So far we've protected data AT REST (hashing) and WHILE BEING PROCESSED (validation, authorization). There's one more gap: data WHILE BEING SENT over the network. Without HTTPS, requests (including the password at login, and the JWT token) are sent as plain text that can be intercepted by anyone "in the middle" of the network path (say, on the same public WiFi).

In our app, TLS/HTTPS is typically NOT implemented in the Python code itself — that's the job of the layer in front of it: a reverse proxy (Nginx, like we used in the Scalability series), a cloud load balancer, or a hosting platform (Vercel, as in the CI/CD series) that handles TLS termination, then forwards traffic to the app over a trusted internal network.

What we CAN do at the application level: tell the browser to ALWAYS use HTTPS via the Strict-Transport-Security header (covered in the next section) — once a browser has received this header once, it automatically refuses to access that same domain over plain HTTP for a set period, even if the user explicitly types http://.

CORS — Controlling Who Can Call the API From a Browser

CORS (Cross-Origin Resource Sharing) is a browser mechanism that by default BLOCKS JavaScript on one domain from calling an API on another domain — unless that API explicitly allows it. Without CORS configured, modern browsers automatically reject API requests from a frontend on a different origin.

app.add_middleware(
    CORSMiddleware,
    allow_origins=[origin.strip() for origin in settings.cors_allowed_origins.split(",")],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

The most common mistake: setting allow_origins=["*"] (allow every origin) to make things "easy, no need to think about it." This is especially dangerous combined with allow_credentials=True — that combination means ANY site on the internet can make a request to our API with the user's credentials (cookies, auth headers) attached. Always list SPECIFIC, trusted origins (cors_allowed_origins in .env), never a wildcard.

Security Headers — Extra Instructions for the Browser

Finally, a handful of HTTP headers that give the browser extra instructions on how to treat our response:

@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
    response.headers["Referrer-Policy"] = "no-referrer"
    return response

Each one:

  • X-Content-Type-Options: nosniff — prevents the browser from "guessing" a response's content type (MIME sniffing), which can sometimes be exploited to execute content as a script when it was only meant to be data.
  • X-Frame-Options: DENY — prevents our page/response from being displayed inside an <iframe> on another site, basic protection against clickjacking (tricking a user into clicking something hidden in an iframe).
  • Strict-Transport-Security — the "always use HTTPS for this domain" instruction, as covered in the previous section.
  • Referrer-Policy: no-referrer — prevents the full URL (which might contain a token or sensitive ID in the query string) from being sent to another site via the Referer header when a user clicks an outbound link.
  • Prove the headers are actually attached:

    curl -s -D - -o /dev/null localhost:8000/health | grep -iE "X-Content-Type-Options|X-Frame-Options|Strict-Transport-Security|Referrer-Policy"
    
    x-content-type-options: nosniff
    x-frame-options: DENY
    strict-transport-security: max-age=63072000; includeSubDomains
    referrer-policy: no-referrer
    

    These headers are free to apply — no added logic complexity, just a handful of lines of middleware — but they close off several classes of attack at once.

    Summary

  • Secrets must never have a default that "silently still works" — use an obviously bad default value, and ideally fail startup entirely if it isn't set (${VAR:?message} in Docker Compose).
  • The fail secure principle: if the configuration is wrong, the system should stop clearly, not quietly continue with a security hole.
  • HTTPS/TLS is usually implemented at the reverse proxy/load balancer, not in the application code — but the app can enforce it via the Strict-Transport-Security header.
  • CORS must allow specific, trusted origins, never a * wildcard combined with allow_credentials=True.
  • Security headers (X-Content-Type-Options, X-Frame-Options, etc.) are cheap to apply, closing off several classes of browser-side attacks at once.
  • Topics

    SecuritySecretsHTTPS