Protecting Routes and APIs
The essential playbook for implementing protecting routes and apis in your SaaS.
Protecting routes and APIs means enforcing access rules at every layer: frontend pages, backend endpoints, admin areas, and internal actions. For a small SaaS, the minimum viable setup is server-side auth verification on every protected request, clear role checks, and consistent 401/403 handling.
This page focuses on practical patterns for Flask and FastAPI apps, plus the production failure points that commonly leave data exposed.
Quick Fix / Quick Setup
Use one shared auth layer for all protected endpoints. Do not put custom permission logic directly inside random handlers.
FastAPI: authenticated route + admin-only route
from fastapi import FastAPI, Depends, HTTPException, status, Header
from typing import Optional
app = FastAPI()
TOKENS = {
"demo-token": {"id": 1, "email": "user@example.com", "role": "user"},
"admin-token": {"id": 2, "email": "admin@example.com", "role": "admin"},
}
def get_current_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing token"
)
token = authorization.split(" ", 1)[1]
user = TOKENS.get(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return user
def require_role(required_role: str):
def checker(user=Depends(get_current_user)):
if user["role"] != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Forbidden"
)
return user
return checker
@app.get("/api/me")
def me(user=Depends(get_current_user)):
return user
@app.get("/api/admin")
def admin_dashboard(user=Depends(require_role("admin"))):
return {"ok": True}Flask: session-based route protection
from flask import Flask, session, jsonify
from functools import wraps
app = Flask(__name__)
app.secret_key = "change-me"
def login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("user_id"):
return jsonify({"error": "Unauthorized"}), 401
return fn(*args, **kwargs)
return wrapper
def role_required(role):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("user_id"):
return jsonify({"error": "Unauthorized"}), 401
if session.get("role") != role:
return jsonify({"error": "Forbidden"}), 403
return fn(*args, **kwargs)
return wrapper
return decorator
@app.get("/dashboard")
@login_required
def dashboard():
return jsonify({"ok": True})
@app.get("/admin")
@role_required("admin")
def admin():
return jsonify({"ok": True})Use this as a baseline only. In production:
- validate signed sessions or real JWTs
- verify token expiry
- verify tenant membership and resource ownership
- never rely only on frontend route guards
Minimum setup rules
- Use session cookies for server-rendered apps
- Use bearer tokens for external/mobile/API consumers
- Return JSON
401 Unauthorizedand403 Forbiddenconsistently for APIs - Protect both page routes and API routes
- Centralize role checks so new routes inherit the same rules
What’s happening
Protected routes must reject requests from unauthenticated users with 401 and reject authenticated-but-not-allowed users with 403.
Key points:
- Frontend route guards improve UX only
- Backend checks are the real security boundary
- Every endpoint returning private data or mutating state must verify identity
- Authorization is usually layered:
- authenticated user
- tenant membership
- resource ownership
- role or permission check
request flow diagram showing browser/app -> auth middleware -> role check -> ownership/tenant check -> handler
Correct response semantics
| Case | Response |
|---|---|
| No session or token | 401 Unauthorized |
| Invalid or expired token/session | 401 Unauthorized |
| Logged in but wrong role | 403 Forbidden |
| Logged in but wrong tenant/resource access | 403 Forbidden |
| Sensitive resource you want to hide | sometimes 404 Not Found, but keep policy consistent |
Step-by-step implementation
1. Define the access model
Classify routes before writing middleware.
Example:
- public: homepage, login, register, health check
- authenticated: dashboard, profile, billing portal
- admin-only: user admin, internal settings, support tools
- tenant-scoped:
/orgs/{org_id}/projects - owner-only: editing a specific private resource
Do this early so route protection is not improvised later.
2. Add one shared auth loader
Load the current user from session or token on every protected request.
FastAPI structure
from fastapi import Depends, Header, HTTPException, status
from typing import Optional
def get_current_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = authorization.split(" ", 1)[1]
# Replace with real verification
user = verify_access_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return {
"user_id": user.id,
"tenant_id": user.tenant_id,
"role": user.role,
"permissions": user.permissions,
}Flask structure
from flask import session, g
@app.before_request
def load_current_user():
g.current_user = None
user_id = session.get("user_id")
if not user_id:
return
user = get_user_by_id(user_id)
if not user:
session.clear()
return
g.current_user = {
"user_id": user.id,
"tenant_id": user.tenant_id,
"role": user.role,
}3. Store minimal identity in request context
Do not pass raw frontend data into authorization checks.
Use:
user_idtenant_idrolepermissions- session or token metadata if needed
This keeps handlers simple and consistent.
4. Wrap protected routes
Apply shared protection helpers everywhere.
FastAPI
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/api/account")
def account(user=Depends(get_current_user)):
return {"user_id": user["user_id"]}Flask
from functools import wraps
from flask import g, jsonify
def login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not g.current_user:
return jsonify({"error": "Unauthorized"}), 401
return fn(*args, **kwargs)
return wrapper
@app.get("/account")
@login_required
def account():
return {"user_id": g.current_user["user_id"]}5. Add role or permission checks
Use central helpers for admin and privileged actions.
FastAPI
def require_role(required_role: str):
def checker(user=Depends(get_current_user)):
if user["role"] != required_role:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return checker
@app.delete("/api/admin/users/{user_id}")
def delete_user(user_id: int, user=Depends(require_role("admin"))):
return {"deleted": user_id}Flask
def role_required(role):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not g.current_user:
return jsonify({"error": "Unauthorized"}), 401
if g.current_user["role"] != role:
return jsonify({"error": "Forbidden"}), 403
return fn(*args, **kwargs)
return wrapper
return decorator
@app.delete("/admin/users/<int:user_id>")
@role_required("admin")
def delete_user(user_id):
return {"deleted": user_id}6. Enforce tenant membership
Do not trust tenant_id from URL, body, or query string without checking membership.
Example check:
def assert_tenant_access(user, tenant_id):
if user["tenant_id"] != tenant_id:
raise HTTPException(status_code=403, detail="Wrong tenant")If users can belong to multiple tenants, check membership in the database:
def user_has_tenant_access(user_id: int, tenant_id: int) -> bool:
return db.session.execute(
"""
select 1
from tenant_memberships
where user_id = :user_id and tenant_id = :tenant_id
limit 1
""",
{"user_id": user_id, "tenant_id": tenant_id},
).scalar() is not None7. Enforce resource ownership
Tenant membership alone is not enough for all records.
Example:
def get_project_for_user(project_id: int, user):
project = find_project(project_id)
if not project:
return None
if project.tenant_id != user["tenant_id"]:
raise HTTPException(status_code=403, detail="Forbidden")
return projectFor sensitive actions, verify permissions against server-side state, not stale client claims.
8. Standardize page vs API behavior
Browser pages and APIs should use the same auth source, but different response styles.
- browser pages: redirect unauthenticated users to login
- APIs: return JSON
401or403
Flask example redirect for pages
from flask import redirect, url_for, request
def page_login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not g.current_user:
return redirect(url_for("login", next=request.path))
return fn(*args, **kwargs)
return wrapperAPI error format
{"error": "Unauthorized"}
{"error": "Forbidden"}Keep this format stable across routes.
9. Test the full matrix
At minimum, test:
- anonymous access
- logged-in user access
- admin-only access
- cross-tenant access
- expired token/session
- revoked role after permissions change
Example pytest ideas:
def test_anonymous_cannot_access_private_api(client):
res = client.get("/api/account")
assert res.status_code == 401
def test_user_cannot_access_admin_api(client, user_token):
res = client.get(
"/api/admin",
headers={"Authorization": f"Bearer {user_token}"}
)
assert res.status_code == 403Common causes
These are the failures that usually break protection in production:
- Protected frontend pages call unprotected backend APIs
- Authentication is checked, but authorization is not
- Session cookie is not sent in production because
Secure/SameSite/ domain settings are wrong Authorizationheader is missing due to proxy, CORS, or frontend request config issues- JWT expiry or signature validation is skipped or implemented incorrectly
- Tenant ID from URL or body is trusted without verifying user membership
- Admin-only routes rely on hidden buttons instead of backend checks
- Middleware is applied to some routers but not all
- Cached user role data is stale after permission changes
- Logout does not invalidate session or token, leaving old access usable
Common failure patterns in code
Hidden UI, open API
Bad:
# frontend hides the button, but backend route is public
@app.post("/api/admin/delete-user")
def delete_user():
...Correct:
@app.post("/api/admin/delete-user")
@role_required("admin")
def delete_user():
...Cookie existence check without validation
Bad:
if request.cookies.get("session"):
# assume logged inCorrect:
- validate signed session
- load session server-side
- reject missing or invalid session state
JWT accepted without verification
Bad:
payload = jwt.decode(token, options={"verify_signature": False})Correct:
import jwt
payload = jwt.decode(
token,
key=PUBLIC_KEY,
algorithms=["RS256"],
audience="your-api",
issuer="https://auth.example.com",
)Debugging tips
Start with direct requests and verify what the backend actually receives.
Basic request checks
curl -i http://localhost:8000/api/me
curl -i -H 'Authorization: Bearer demo-token' http://localhost:8000/api/me
curl -i -H 'Authorization: Bearer bad-token' http://localhost:8000/api/me
curl -i -H 'Authorization: Bearer admin-token' http://localhost:8000/api/admin
curl -i --cookie 'session=<your-session-cookie>' http://localhost:5000/dashboardFind unprotected routes
grep -R "login_required\|role_required\|Depends(get_current_user)" .
grep -R "@app\.get\|@router\.get\|@app\.post\|@router\.post" .Compare these outputs. Routes without protection wrappers are candidates for exposure.
Run targeted tests
pytest -k 'auth or permissions or protected' -qInspect route registration
uvicorn app:app --reload
flask routesCheck proxy and header forwarding
If auth works locally but fails behind Nginx or a platform proxy, verify that the Authorization header is forwarded.
Example Nginx config:
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
}Check session cookie settings
For cross-site setups or separate frontend/backend hosts, cookie config often breaks login state.
Example Flask config:
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="Lax", # or "None" if cross-site + HTTPS
)If using SameSite=None, Secure=True is required.
Add temporary auth logging
Log auth decisions without logging raw secrets.
logger.info(
"auth_check",
extra={
"path": request.url.path,
"user_id": getattr(g, "current_user", {}).get("user_id") if getattr(g, "current_user", None) else None,
"role": getattr(g, "current_user", {}).get("role") if getattr(g, "current_user", None) else None,
},
)Do not log:
- raw JWTs
- session cookies
- password reset tokens
- API secrets
Checklist
- ✓ All private API routes enforce backend authentication
- ✓ Admin and staff endpoints enforce backend authorization
- ✓ Tenant-scoped endpoints verify membership and ownership
- ✓
401and403responses are distinct and consistent - ✓ Session cookies are
Secure,HttpOnly, andSameSiteconfigured correctly - ✓ JWT validation checks signature and
exp - ✓ Frontend route guards exist only as UX helpers
- ✓ Tests cover anonymous, user, admin, and cross-tenant cases
- ✓ Role or permission checks are centralized
- ✓ Logout invalidates session or token state where required
Suggested visual: permission matrix table mapping route groups to required auth and role.
Related guides
- Structuring a Flask/FastAPI SaaS Project
- SaaS Architecture Overview (From MVP to Production)
- Choosing a Tech Stack for a Small SaaS
- SaaS Production Checklist
FAQ
What is the minimum protection I should add for an MVP?
Require authentication on all private routes, add role checks for admin actions, and verify tenant or resource ownership on data access endpoints.
Should I return 404 instead of 403 for forbidden resources?
You can for sensitive resources to reduce enumeration, but keep the policy consistent. Internally, still log the access denial clearly.
Can I trust role claims inside a JWT?
Only if the token is properly signed and short-lived. For sensitive actions, re-check permissions against server-side state when possible.
How do I protect both web pages and APIs?
Use the same identity source and shared auth helpers, but tailor responses:
- redirects for browser pages
- JSON
401/403for APIs
Should frontend route guards be enough?
No. They only hide UI or redirect users. The backend must enforce access.
Should I use sessions or JWTs?
Sessions are usually simpler for web SaaS apps. JWTs fit external APIs and distributed consumers better.
What status code should I return?
Use 401 for missing or invalid authentication and 403 for authenticated users without permission.
Do I need role checks on every admin route?
Yes. Centralize them so the check is automatic and hard to forget.
Final takeaway
Protect routes at the backend first, then add frontend guards for UX.
Use shared auth middleware or dependencies, explicit role checks, and tenant/resource validation on every private endpoint.
Most production auth leaks come from inconsistent enforcement, not cryptography failures.