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

python
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

python
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 Unauthorized and 403 Forbidden consistently 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
browser/app
auth middleware
role check
ownership/tenant check
handler

request flow diagram showing browser/app -> auth middleware -> role check -> ownership/tenant check -> handler

Correct response semantics

CaseResponse
No session or token401 Unauthorized
Invalid or expired token/session401 Unauthorized
Logged in but wrong role403 Forbidden
Logged in but wrong tenant/resource access403 Forbidden
Sensitive resource you want to hidesometimes 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

python
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

python
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_id
  • tenant_id
  • role
  • permissions
  • session or token metadata if needed

This keeps handlers simple and consistent.

4. Wrap protected routes

Apply shared protection helpers everywhere.

FastAPI

python
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

python
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

python
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

python
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:

python
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:

python
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 None

7. Enforce resource ownership

Tenant membership alone is not enough for all records.

Example:

python
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 project

For 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 401 or 403

Flask example redirect for pages

python
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 wrapper

API error format

python
{"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:

python
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 == 403

Common 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
  • Authorization header 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:

python
# frontend hides the button, but backend route is public
@app.post("/api/admin/delete-user")
def delete_user():
    ...

Correct:

python
@app.post("/api/admin/delete-user")
@role_required("admin")
def delete_user():
    ...

Cookie existence check without validation

Bad:

python
if request.cookies.get("session"):
    # assume logged in

Correct:

  • validate signed session
  • load session server-side
  • reject missing or invalid session state

JWT accepted without verification

Bad:

python
payload = jwt.decode(token, options={"verify_signature": False})

Correct:

python
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

bash
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/dashboard

Find unprotected routes

bash
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

bash
pytest -k 'auth or permissions or protected' -q

Inspect route registration

bash
uvicorn app:app --reload
flask routes

Check 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:

nginx
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:

python
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.

python
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
  • 401 and 403 responses are distinct and consistent
  • Session cookies are Secure, HttpOnly, and SameSite configured 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

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/403 for 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.