Secure Deployment Checklist for a Private FastAPI Service
A concrete, checkable secure-deployment checklist for putting a private FastAPI service into production without leaving the obvious holes open.
Part 7 of a 9-part series: teaching CompTIA A+ (Core 1 / 220-1101 and Core 2 / 220-1102) through a real build, a private, local AI workstation/server for a small business.
The service works on localhost, now make it safe to expose
You've built a FastAPI backend that powers the whole stack: agents, file uploads, model dispatch, user management. It answers every request correctly on localhost. The next step is making it reachable from the company's internal network, and that's the moment things get dangerous if you skip the checklist.
Exposing a service to even a trusted LAN is a different threat surface than loopback. The password endpoint that "worked fine in testing" is one open port away from a credential-stuffing script. The development JWT secret in an env file gets committed to git, and the private repo isn't as private as assumed. The admin panel with no authentication because "only I know the URL" is now accessible to anyone on the subnet.
None of this is theoretical. Every item on the checklist below addresses a class of gap that, left open, becomes a real incident. The walkthrough treats the deployment gate as a pre-flight: the service doesn't go live until every box is checked. It also maps directly to the A+ security and operational-procedures domains, because the exam is testing the same professional discipline, just described in its own vocabulary.
📘 Objectives covered (220-1102) >Core 2 (220-1102)- 2.x, Security: authentication concepts (password, token, MFA factors); secure vs. insecure protocols (HTTPS/SSH vs. HTTP/Telnet); account lockout policy; least privilege and role-based access; failure logging as a security control.- 4.x: Operational Procedures: repeatable deployment checklists; documentation and change management; secure credential and secrets handling. >Concepts taught: HTTPS/TLS termination via reverse proxy, JWT/session tokenarchitecture, TOTP and WebAuthn as MFA factors, brute-force lockout andfailure logging, secure HTTP headers, secrets management, and a numberedsecure-deployment checklist.
Concepts: what "secure deployment" actually means
HTTPS/TLS: encrypt the wire (1102 2.x)
The single most important line on the checklist is "terminate TLS before the first real request reaches the service." The A+ exam and the real world agree on this: HTTP is plaintext; HTTPS is encrypted. Credentials, session tokens, and API responses travelling over plain HTTP are readable by anyone on the network path: even a local network.
The standard pattern for a containerized service: put a reverse proxy (Caddy or nginx) in front of Uvicorn. The proxy holds the certificate, does the TLS handshake, then forwards decrypted HTTP to the backend on a loopback address. Uvicorn never sees TLS; it only speaks plain HTTP to localhost. The separation is clean: the proxy is purpose-built for cipher selection and certificate renewal; the backend only handles application logic.
TLS version policy: enforce minimum_version = TLSv1_2. SSLv3 and TLS 1.0/1.1 are cryptographically broken. The cipher list selects ECDHE suites with forward secrecy: a compromised private key cannot decrypt past-captured sessions.
Certificate options for an internal deployment: a public CA cert (Let's Encrypt via DNS challenge) works even for internal hostnames via split-horizon DNS; a private CA cert (self-run CA, root installed on all internal machines) is perfectly valid for browser-facing traffic when you control all clients; a self-signed cert is acceptable only on a single developer's localhost, not for production, even internal.
The A+ exam frames it simply: HTTP = insecure, HTTPS = encrypted. A valid, trusted cert at every layer is the baseline everything else sits on top of.
JWT tokens and sessions (1102 2.x)
After TLS, the next question is identity. Two complementary mechanisms cover it.
JWTs (JSON Web Tokens) are self-contained: the server signs a JSON payload, the client presents the token on every request, and the server verifies the signature without a database round-trip. Two token types:
- Access token: short-lived (15–30 minutes), carries full user claims (
sub,role,org_id), delivered asAuthorization: Bearer <token>. - Refresh token: long-lived (7 days), minimal claims, rotated on use: a stolen refresh token can be used at most once before it's invalidated.
Signing algorithm: RS256 (asymmetric RSA + SHA-256) lets verification nodes hold only the public key. HS256 (symmetric HMAC) requires sharing the secret on both sides: less safe in a distributed context.
Server-side sessions record each login in a user_sessions table. The JWT carries a session_id claim; the middleware cross-references it on refresh. A revoked row means the next refresh fails: this is the revocation path short-lived JWTs alone can't provide.
For the exam: authentication = who are you (sub claim); authorization = what can you do (role claim).
Multi-factor authentication: TOTP and WebAuthn (1102 2.x)
A password alone is a single factor: something you know. MFA requires two or more factors from different categories. The three categories are:
- Something you have: a physical token, TOTP authenticator app (Google Authenticator, Aegis), smart card, or hardware security key (YubiKey, FIDO2/WebAuthn token). TOTP produces a time-based 6-digit code from a shared secret seeded at enrollment; valid for ~30 seconds. A WebAuthn/FIDO2 key also belongs here: the private key never leaves the token, the server stores only the public key + credential ID, and auth is a challenge-response: no secret transmitted. Two passwords is not MFA; a password plus a TOTP code is.
- Something you are: biometrics, fingerprint, face, retina. When a WebAuthn authenticator requires a fingerprint tap to unlock, that biometric is a local unlock for the device key; the server-side factor is still something you have.
The TOTP login flow: password check passes → server issues a short-lived MFA challenge token (5-min TTL, only authorized to submit one TOTP code) → client sends challenge token + 6-digit TOTP → if valid, session is created and the real access/refresh pair is issued. Wrong password or wrong code both produce the same 401 Invalid credentials: no enumeration possible.
Brute-force lockout and failure logging (1102 2.x / 4.x)
Account lockout policy: after N consecutive failures within a window, the account is locked for a cooldown period: the A+ exam explicitly names this control. Without it, a credential-stuffing script runs indefinitely.
Every authentication failure writes a row to auth_failure_events: timestamp, failure type (bad password / bad TOTP / expired token / locked account), user, IP, and method. A violation detector reads these rows on each tick; when it sees more than N failures from the same IP across different usernames, it writes an ip_blocks row and IPBlockMiddleware short-circuits those requests before any auth logic runs. Account lockout stops single-target brute-force; IP-blocking stops cross-account credential stuffing.
The failure log writes to its own database session, independent of the request transaction about to return 401. If the request rolls back, the audit row still commits: by design.
Secure HTTP headers and middleware (1102 2.x)
HTTPS encrypts the wire; secure response headers tell the browser how to behave with the payload. Set these at the reverse proxy so they apply to every response:
Header | Purpose |
|---|---|
| Forces HTTPS for future visits; prevents SSL stripping |
| Blocks iframe embedding (clickjacking defense) |
| Stops MIME-type sniffing |
| Controls which origins load scripts and styles |
The IPBlockMiddleware is registered last in add_middleware, so it runs first on the inbound path. Blocked IPs short-circuit before any auth or route work happens.
Secrets management (1102 4.x)
Three hard rules for secrets:
- Never commit to git.
.envbelongs in.gitignore. If one slips through, rotate immediately: history is permanent even after the file is deleted. - Load from environment or a secrets store. The config layer reads
JWT_SECRET_KEY,POSTGRES_PASSWORD, etc. from environment variables at startup; a production install seeds them from a secrets manager (Vault or equivalent). - Use strong random values. A JWT secret needs at least 256 bits of entropy:
openssl rand -base64 32. Not a human-memorable string.
Hands-on walkthrough: middleware setup + the numbered checklist
The middleware stack
The application registers middleware in reverse execution order, add_middleware wraps outer-to-inner, so the last one added runs first on inbound requests. Here's the middleware registration, with the security rationale inline:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from auth.context import UserContextMiddleware
from auth.ip_block import IPBlockMiddleware
app = FastAPI(
title="Private AI Service API",
docs_url=None, # Disable /docs in production — don't advertise
redoc_url=None,
openapi_url=None,
)
# CORS — restrict allowed origins to known frontends.
# Never use allow_origins=["*"] in production with credentials=True.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.internal"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
# Resolve bearer / session / API-key → user context on every request.
app.add_middleware(UserContextMiddleware)
# IP block check — added LAST, runs FIRST on inbound path.
# Blocked IPs short-circuit before auth resolution work happens.
app.add_middleware(IPBlockMiddleware)Note docs_url=None and openapi_url=None. In development, Swagger UI is invaluable; in production it's an endpoint inventory handed to an attacker. Disable it or put it behind the same auth gate as the rest of the API.
The login and MFA flow
The auth endpoint is a two-step pattern. Happy path for a user with MFA enrolled:
# POST /api/v1/auth/login
# Body: {"username": "alice", "password": "..."}
# 1. Look up user — return 401 for unknown/locked/inactive;
# same error message for all cases (no enumeration)
user = await _lookup_user_for_login(db, payload.username)
if user is None or _user_is_locked(user):
await record_auth_failure(event_type=..., request=request, ...)
raise HTTPException(status_code=401, detail="Invalid username or password")
# 2. Verify password (constant-time compare)
if not verify_password(payload.password, user.password_hash):
await record_auth_failure(event_type=BAD_PASSWORD, ...)
raise HTTPException(status_code=401, detail="Invalid username or password")
# 3. If MFA enrolled, issue a challenge token (not a session token)
if await _has_verified_mfa(db, user.id):
challenge_token = _create_mfa_challenge_token(user) # 5-min TTL
return {"mfa_required": True, "mfa_challenge_id": challenge_token,
"access_token": "", "refresh_token": ""}
# 4. No MFA — create session and issue token pair directly
session = await _create_user_session(db, user=user, mfa_verified=False, ...)
tokens = _issue_token_pair(user, session.session_id)
return {"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
"token_type": "bearer", "expires_in": 1800}Two security details worth naming:
- The lockout check returns the same 401 as a wrong password: an attacker can't distinguish "wrong credentials" from "account locked."
record_auth_failureis on every rejection path before the 401 is raised. No path returns 401 without logging: the detector sees 100% of failure events.
The numbered secure-deployment checklist
This is the centerpiece. These are the gates the service must pass before traffic is routed to it from the internal network. Print it, check it off, commit the result to your change record.
Pre-exposure checklist for a private FastAPI service
- TLS is terminated by the reverse proxy, not Uvicorn directly. Verify:
curl -k https://node-1.example.internal/healthreturns 200;curl http://node-1.example.internal/healthreturns a redirect or is blocked. - Certificate is valid and trusted by internal clients. Verify:
openssl s_client -connect node-1.example.internal:443 -CAfile /etc/certs/internal-ca.crtshowsVerify return code: 0 (ok). A self-signed cert without trust is not acceptable. - TLS 1.2 is the minimum; TLS 1.0/1.1/SSLv3 are disabled. Verify:
openssl s_client -connect node-1.example.internal:443 -tls1_1fails withno protocols available(or similar rejection). - Authentication is required on every non-health endpoint. Verify:
curl https://node-1.example.internal/api/v1/agents→ 401. The health endpoint (/health) is the only legitimate exception. - MFA is enforced for all accounts, not optional. Verify: a freshly created account with no MFA device cannot complete login and get a real token. Configuration, not hope.
- Account lockout is active. Verify: send 5+ incorrect passwords to
/api/v1/auth/loginfor a test account → subsequent login attempts with the correct password should be rejected (same 401) until the lockout expires. Check theauth_failure_eventstable: rows exist for each attempt. - IP-based blocking is active. Verify: send 10+ failures from a test IP; check
ip_blockstable for a row; confirm subsequent requests from that IP return 403 before hitting any auth logic. - Failure events are being written to the database. Verify: intentionally fail a login; query
SELECT * FROM auth_failure_events ORDER BY created_at DESC LIMIT 5;: the event appears. - JWT secrets and database passwords are loaded from environment, not from committed files. Verify:
grep -r "JWT_SECRET_KEY\|POSTGRES_PASSWORD" .finds only.env.example(with placeholder values): never a file with real values. Rungit log --all --full-history -- '*.env': no.envfile in history. - Swagger/OpenAPI docs are disabled or auth-gated. Verify:
curl https://node-1.example.internal/docs→ 404 (disabled) or → 401 (auth-gated). Not a 200. - Secure HTTP headers are present on responses. Verify:
curl -I https://node-1.example.internal/healthshowsStrict-Transport-Security,X-Frame-Options, andX-Content-Type-Optionsin the response headers. - Session tokens are stored in
HttpOnlycookies or used as bearer tokens inAuthorizationheaders: never in URLs. TheHttpOnlyflag prevents JavaScript from reading the cookie, so a cross-site scripting (XSS) attack can't steal a token it can't access. Verify: inspect the login response; theaccess_tokenvalue appears only in the response body, never appended to a URL. URLs appear in logs; headers and body do not. - Token expiry is enforced. Verify: take a valid access token, wait for its TTL to pass (or manually set
expin the past in a test environment), and confirm subsequent requests return 401. - The service starts cleanly from a documented procedure. Verify: a second operator, following only the written runbook, can bring the service up from scratch. If they can't, the runbook is incomplete.
Verification: prove the checklist was checked
Three commands cover the three most critical gates:
# 1. TLS: must show Verify return code: 0 (ok)
openssl s_client -connect node-1.example.internal:443 \
-CAfile /etc/certs/internal-ca.crt < /dev/null 2>&1 \
| grep -E "^(subject|issuer|Verify)"
# 2. Auth: unauthenticated request must return 401
curl -sf https://node-1.example.internal/api/v1/users/me
# Expected: {"detail":"Not authenticated"}
# 3. Lockout + audit log: fire bad logins, confirm events land in DB
for i in {1..5}; do
curl -s -X POST https://node-1.example.internal/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test-user","password":"wrong"}' | jq .detail
done
docker exec -it postgres-container psql -U appuser -d appdb \
-c "SELECT event_type, ip_address, created_at FROM auth_failure_events
ORDER BY created_at DESC LIMIT 5;"
# 4. IP-block persistence: after crossing the failure threshold, confirm a row
docker exec -it postgres-container psql -U appuser -d appdb \
-c "SELECT ip_address, blocked_at, expires_at FROM ip_blocks
ORDER BY blocked_at DESC LIMIT 5;"If auth_failure_events rows don't appear, the audit pipeline is broken. If ip_blocks rows don't appear after the threshold, the violation detector is not running or the middleware is not wired. Fix both before going live: a control you can't observe is one you can't trust.
🎯 What the exam asks >The A+ security domain has predictable question patterns. Know these: >Multi-factor authentication categories:- CompTIA defines three factor types: something you know (password, PIN), something you have (TOTP authenticator, hardware key, smart card), and something you are (biometrics, fingerprint, retina, voice). MFA = two or more different categories. Two passwords is not MFA; a password + TOTP code is. Exam scenarios that ask "which is the strongest form of authentication" point toward hardware keys / biometrics over passwords alone. >Secure vs. insecure protocols:- The exam tests this as a paired comparison: HTTPS (port 443, encrypted) vs. HTTP (port 80, plaintext); SSH (port 22, encrypted) vs. Telnet (port 23, plaintext); SFTP vs. FTP; SNMP v3 vs. SNMP v1/v2. The pattern is always: the secure version encrypts; the insecure version transmits credentials in cleartext. A scenario that asks "which protocol should you use for remote server administration", the answer is SSH, not Telnet, regardless of which is "easier to set up." >Account lockout policy:- This is explicitly listed in 1102 2.x. The exam frames it as: after a configurable number of failed login attempts, the account is locked for a configurable duration. This defends against brute-force and *credential- stuffing* attacks. Know the distinction: brute-force tries every possible password against one account; credential stuffing uses known breach username/password pairs across many accounts. >Deployment checklist as an operational procedure:- The 1102 4.x domain covers operational procedures, and the exam's version of this is: "before making a change, document it; after, verify it; always have a rollback plan." A secure-deployment checklist is the implementation of that principle. If an exam scenario describes a service that was deployed without documentation and then broke, the correct answer almost always involves "establish proper change management procedures." >Principle of least privilege:- Applies to service accounts, API keys, and user roles. A service that only needs to read from a database should not have write or admin access to it. The exam tests this in scenarios where an overly permissive account is the root cause of a security incident.
Common pitfalls (most of these happened in the real build)
- Tokens in URLs. Adding
?token=<jwt>to a URL puts the credential in access logs, browser history, andRefererheaders. Tokens belong inAuthorizationheaders orHttpOnlycookies, never in URLs. - No lockout means credential stuffing works. Without lockout and IP-blocking, a script tries thousands of pairs indefinitely. You learn about it from a breach notice, not a log alert.
- Self-signed cert without installing trust. The client clicks through the warning (or skips verification in code). The "encrypted" connection provides no protection against a network-level attacker. Use a proper CA, public or private, and install the trust anchor.
- MFA as opt-in instead of enforced. High-privilege users are the most likely to skip optional security steps. They are also the highest-value targets. Enforce MFA at the auth layer; don't offer it as a user preference.
- JWT secret committed to git.
git log --allretrieves it forever after the file is deleted. If a secret touches history, rotate it immediately. - Failure logging that fires on some paths but not others. Adding a new auth endpoint and forgetting
record_auth_failureleaves that surface dark: the IP-blocking that would have fired doesn't. Call the failure logger on every rejection branch, before raising the 401. - No rollback plan. The checklist catches security gaps; the change record documents how to undo them. "We need to figure out rollback" is the worst possible time to start figuring it out.
Recap + what's next
The service went from "works on localhost" to "safe to expose" by running a pre-flight: TLS at the proxy, JWT/session architecture with token rotation, TOTP and WebAuthn as enforced second factors, brute-force lockout with IP-blocking, and a 14-item checklist that generates evidence (audit rows you can query) rather than assertions you have to trust.
That repeatability is the point. The operational-procedures domain asks exactly this: not "did you secure this once," but "do you have a process that makes security a property of deployment, not a late addition."
The service is live. Now someone runs a bad migration and data is corrupted. The only question that matters: when was the last good backup, and can you actually restore from it?
Next up, Part 8: "Backups, Recovery & Change Management for a Self-Hosted AI Stack." Backup types (full, incremental, differential, snapshot, WAL shipping), the 3-2-1 rule, WORM retention for audit logs that can't be tampered with, and the change management discipline that prevents the next migration disaster: covering 1102 4.x operational procedures, the most-skipped exam domain and one of the most consequential real-world skills. See you there.
