In this article
February 20, 2026
February 20, 2026

Building authentication in Python web applications: The complete guide for 2026

Master secure authentication patterns across Django, FastAPI, and Flask, with production-ready examples for 2026.

Authentication in Python web applications is more critical than ever in 2026. With AI-powered applications, microservices architectures, and enterprise B2B requirements, Python developers must navigate complex security considerations while building fast, scalable authentication systems. Whether you're using Django's batteries-included approach, Flask's flexibility, or FastAPI's async-first design, understanding authentication fundamentals is essential.

This comprehensive guide covers everything you need to know about authentication in Python: from core concepts and security patterns to framework-specific implementations and production best practices. Whether you're building authentication from scratch or evaluating managed solutions, you'll gain the knowledge to make informed decisions for your Python application. Let's dive right in.

How authentication works in Python

Python's web frameworks handle authentication differently than JavaScript frameworks. While frameworks like Next.js blur the line between client and server, Python frameworks maintain a clearer separation: your Python code runs on the server, and authentication happens before any response reaches the client.

The Python request-response cycle

Understanding how Python web servers handle requests is foundational:

  1. Request arrives: The server receives the HTTP request and routes it to your application.
  2. Middleware/decorator execution: Framework-specific middleware or decorators run before your view/handler code. This is where initial authentication checks happen.
  3. View/handler execution: Your route handler processes the authenticated request, accessing databases and business logic.
  4. Response generation: Python generates the HTTP response (JSON, HTML, redirect) and sends it back.
  5. Session persistence: If authentication succeeded, session data is stored for subsequent requests.

The key insight here is that Python authentication is server-side by design. Unlike client-side JavaScript frameworks, Python never sends authentication logic to the browser.

WSGI vs ASGI: The foundation

Python web frameworks run on one of two server interfaces:

  • WSGI (Web Server Gateway Interface):
    • Traditional, synchronous.
    • Django and Flask run on WSGI by default.
    • One request per worker thread.
    • Simpler to understand and debug.
    • Mature ecosystem (Gunicorn, uWSGI).
    • Threads block while waiting on I/O.
  • ASGI (Asynchronous Server Gateway Interface):
    • Modern, async.
    • FastAPI runs exclusively on ASGI.
    • Django 3.0+ supports ASGI (many features still synchronous).
    • Multiple concurrent requests per worker.
    • Critical for WebSockets, streaming, concurrent external API calls.
    • More complex error handling.

With regards to authentication, the following apply:

  • JWT validation works fine with both: Validating a JWT token is a CPU-bound operation that takes 5-10ms. Since there's no I/O wait (no network calls or database queries), WSGI's synchronous nature doesn't create a bottleneck. The cryptographic operations (signature verification, expiration checks) happen in memory and complete quickly regardless of whether you're using WSGI or ASGI.
  • Database session lookups benefit from ASGI's async: When you store sessions in a database, each authentication check requires a database query. With WSGI, the worker thread blocks while waiting for the database to respond (~30-50ms). With ASGI, the worker can handle other requests while waiting for the database. This becomes significant under load: a WSGI server with 10 workers can handle 10 concurrent session lookups, while an ASGI server with 10 workers can handle hundreds of concurrent lookups.
  • OAuth flows work with both: OAuth redirects users to external providers (Google, GitHub) and back to your application. These flows involve HTTP redirects rather than long-running connections, so both WSGI and ASGI handle them equally well. The user's browser does the waiting, not your server.
  • SSO integrations see better performance with ASGI: Enterprise SSO (SAML, OIDC) requires calling external identity providers to verify tokens and fetch user information. These external calls can take 100-500ms. With ASGI, your server can handle dozens of other requests while waiting for the IdP to respond. With WSGI, each SSO verification blocks a worker thread. For B2B applications where multiple enterprise customers authenticate simultaneously, ASGI can provide 3-5x better throughput.

Choose your framework based on overall needs, not just authentication requirements.

Framework comparison: Django vs Flask vs FastAPI

Aspect Django Flask FastAPI
Philosophy Batteries-included Minimalist Async-first
Learning Curve Steeper Gentle Medium
Built-in Auth Yes (comprehensive) No (extensions) Partial (OAuth2/JWT)
Performance Good Good Excellent (2-3x)
Async Support Partial (3.0+) Limited Native
Best For Full-stack B2B Custom APIs High-performance APIs
Admin Panel Built-in Build yourself Build yourself

Django's auth strengths:

  • Built-in user model and auth system.
  • Admin interface for user management.
  • Middleware handles sessions automatically.
  • Password hashing, permissions, groups included.

Flask's auth strengths:

  • Complete control over authentication.
  • Choose your own auth library (Flask-Login, Flask-Security).
  • Lightweight; only include what you need.
  • Easy to understand; minimal "magic".

FastAPI's auth strengths:

  • OAuth2, JWT support built-in.
  • Async-compatible authentication flows.
  • Automatic request validation with Pydantic.
  • Dependency injection makes auth elegant.
  • Fastest performance of the three.

Critical security considerations

Authentication security in Python requires constant vigilance. While Python's memory safety helps prevent certain vulnerabilities, application-level security remains your responsibility.

Pickle deserialization attacks

Pickle is Python's native serialization format, and it's tempting to use for storing complex session data. However, pickle is fundamentally unsafe when deserializing untrusted data. When Python unpickles data, it doesn't just reconstruct objects, it can execute arbitrary code. An attacker who can control pickled data can craft malicious payloads that execute commands on your server, install backdoors, steal credentials, or completely compromise your application.

The attack works because pickle can serialize Python callables (functions, classes) and their arguments. When unpickled, these callables execute. An attacker can serialize a call to os.system('malicious command') or eval('malicious code'), and your server will dutifully execute it.

	
# ❌ DANGEROUS: Never unpickle user-controlled data
import pickle
session_data = pickle.loads(request.cookies.get('session'))
# An attacker can craft a cookie that executes: os.system('rm -rf /')

# ✅ SAFE: Use JSON or cryptographically signed tokens  
from itsdangerous import URLSafeTimedSerializer
serializer = URLSafeTimedSerializer(secret_key)
session_data = serializer.loads(request.cookies.get('session'))
# itsdangerous signs the data - tampering is detected and rejected
  

SQL injection via ORM misuse

Even when using an ORM like Django's or SQLAlchemy, developers can accidentally introduce SQL injection vulnerabilities by using raw SQL or string formatting. SQL injection allows attackers to execute arbitrary SQL commands, potentially bypassing authentication entirely, dumping your entire user database, or modifying data.

The vulnerability occurs when you build SQL queries by concatenating user input directly into the query string. For example, if an attacker enters ' OR '1'='1 as their email, they can bypass authentication checks.

	
# ❌ DANGEROUS: String formatting creates SQL injection
email = request.POST.get('email')
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")
# Input: ' OR '1'='1 results in: SELECT * FROM users WHERE email = '' OR '1'='1'
# This returns ALL users, bypassing authentication

# ✅ SAFE: Use parameterized queries
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])
# The database driver properly escapes the parameter

# ✅ SAFE: Use ORM methods
User.objects.filter(email=email)
  

SQL injection in authentication code is catastrophic. Attackers can log in as any user, extract password hashes, or modify user roles to grant themselves admin access.

Timing attacks on password comparison

When you compare two strings with == in Python, the comparison stops as soon as it finds a mismatch. This means comparing "password" with "p" is faster than comparing "password" with "passwore". An attacker can measure these tiny timing differences (microseconds) to guess passwords character by character.

By measuring response times across thousands of requests, attackers can statistically determine each character of the password hash.

	
# ❌ VULNERABLE: Timing attack possible
if user.password == provided_password:
    return True
# Stops comparing as soon as characters differ
# Attacker can measure timing to guess password character-by-character

# ✅ SAFE: Constant-time comparison
import secrets
if secrets.compare_digest(user.password, provided_password):
    return True
# Takes the same time regardless of where strings differ
  

Critical vulnerabilities 2025-2026

CVE-2025-3248 (Langflow - CVSS 9.8)

Critical vulnerability in Langflow AI orchestration platform allowed unauthenticated RCE through decorator injection. The vulnerability exploited Python's decorator evaluation behavior.

Impact: Full compromise of AI infrastructure. Added to CISA KEV catalog May 5, 2025.

Lesson: Input validation must happen before any Python code evaluation.

CVE-2025-10164 (SGLang - CVSS 7.3)

Unsafe deserialization in ML inference framework allowed RCE through malicious tensor data.

Lesson: Never deserialize untrusted data. Use safe serialization formats like JSON.

Defense-in-Depth

Defense-in-depth means implementing multiple layers of security so that if one layer fails, others still protect your application. This is critical because no single security mechanism is perfect.

Consider a real-world scenario: An attacker discovers a way to bypass your middleware (like CVE-2025-29927 in Next.js). If middleware is your only authentication check, your entire application is compromised. With defense-in-depth, even if middleware fails, your route handlers still verify authentication, and your data access layer provides a final check.

The three layers in Python are:

	
# Layer 1: Decorator/Middleware for route protection (fast rejection)
@login_required
def protected_view(request):
    # This blocks obviously unauthenticated requests quickly
    # But decorators/middleware can have bugs or be bypassed
    
    # Layer 2: Verify specific permissions (capability check)
    if not request.user.has_perm('app.view_sensitive_data'):
        raise PermissionDenied
    # User is authenticated, but do they have the right permission?
    
    # Layer 3: Object-level authorization (resource ownership)
    obj = get_object_or_404(SensitiveData, id=data_id)
    if obj.owner != request.user and not request.user.is_staff:
        raise PermissionDenied
    # User has permission, but do they own THIS specific resource?
    
    # Only now is it safe to return data
    return JsonResponse({'data': obj.content})
  

Each layer serves a different purpose:

  • Layer 1 (middleware/decorator): Stops completely unauthenticated requests. Fast and efficient. Prevents anonymous users from even reaching your code. But can be bypassed through framework bugs or misconfiguration.
  • Layer 2 (permission check): Verifies the authenticated user has the capability to perform this action. Prevents authenticated users from accessing features they shouldn't. An authenticated regular user can't access admin functions even if they bypass Layer 1.
  • Layer 3 (object-level authorization): Ensures the user can access this specific resource. Prevents users from accessing other users' data. Even if a user has permission to view documents generally, they can't view your documents without this check.

Let's see how a real-world attack scenario plays out without defense-in-depth:

  1. Attacker finds middleware bypass vulnerability.
  2. Attacker crafts request to /api/users/123/profile.
  3. No middleware check runs (bypassed).
  4. View returns user 123's profile data.
  5. Result: Any user's profile accessible.

Now let's see the same scenario with defense-in-depth:

  1. Attacker finds middleware bypass vulnerability.
  2. Attacker crafts request to /api/users/123/profile.
  3. No middleware check runs (bypassed).
  4. Permission check: Does this user have view_profile permission? ✓
  5. Object-level check: Does this user own profile 123? ✗
  6. Request denied with 403 Forbidden.
  7. Result: Attack prevented by Layer 3 (object-level authorization).

Layer 2 (permission check) passed because the attacker is a legitimate authenticated user who has permission to view profiles in general. But Layer 3 (object-level check) caught that they're trying to view someone else's profile. This demonstrates why you need all three layers: each checks something different.

Session security

Session cookies are the keys to your authentication kingdom. If an attacker steals a valid session cookie, they can impersonate that user completely, no password needed. This makes session cookie configuration one of your most critical security decisions.

Important security flags:

  • HTTPOnly: When HTTPOnly is True, JavaScript cannot access the cookie through document.cookie. This is crucial because Cross-Site Scripting (XSS) attacks inject malicious JavaScript into your pages. Without HTTPOnly, an attacker who finds an XSS vulnerability can steal all session cookies with: document.cookie. With HTTPOnly, even if an attacker injects JavaScript, they can't read the session cookie. The cookie is only sent with HTTP requests and is never exposed to client-side code.
  • Secure: When Secure is True, the browser only sends cookies over HTTPS. Without this flag, session cookies transmit in plain text over HTTP. Anyone on the same network (coffee shop WiFi, corporate network) can intercept these cookies using packet sniffing tools. Always set Secure=True in production. In development, use Secure=False since localhost uses HTTP.
  • SameSite='Lax': Cross-Site Request Forgery (CSRF) tricks users into making authenticated requests from malicious websites. For example, a malicious site includes <img src="https://yourapp.com/api/delete-account">. Without SameSite, the browser automatically includes your session cookie, deleting the user's account. SameSite='Lax' prevents cookies from being sent with cross-site requests initiated by other websites, but allows cookies when users click legitimate links. This blocks most CSRF attacks while maintaining normal navigation.
  • Session expiration: Shorter durations (1-2 hours) are more secure. Longer durations (7-30 days) are more convenient. Most applications use 7 days as a middle ground. For high-security applications (banking, healthcare), use 1-2 hour sessions.
	
# Django - Apply all security flags
SESSION_COOKIE_HTTPONLY = True  # Blocks JavaScript access
SESSION_COOKIE_SECURE = True    # HTTPS only
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection
SESSION_COOKIE_AGE = 604800     # 7 days

# Flask
from datetime import timedelta
app.config.update(
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE='Lax',
    PERMANENT_SESSION_LIFETIME=timedelta(days=7),
)

# FastAPI with JWT
from fastapi import Response

@app.post("/login")
async def login(response: Response):
    token = create_access_token({"sub": user_id})
    response.set_cookie(
        key="session",
        value=token,
        httponly=True,  # No JavaScript access
        secure=True,    # HTTPS only
        samesite="lax", # CSRF protection
        max_age=604800, # 7 days
    )
  

Password security

Storing passwords in plain text is one of the most dangerous security mistakes you can make. When your database is compromised (and you should always assume it eventually will be), plain text passwords expose not just your application, but every other service where users reused that password. That's where hashing and salting can save you and keep your users safe:

  • Password hashing is a one-way cryptographic function. You can turn a password into a hash, but you cannot reverse a hash back into the password. This means even if an attacker steals your database, they cannot directly recover user passwords. Modern password hashing algorithms like bcrypt, PBKDF2, and Argon2 are deliberately slow (taking 100-300ms per hash). This slowness doesn't hurt legitimate users (who hash one password per login), but makes brute-force attacks on stolen password databases impractical. An attacker trying billions of password guesses would take years instead of hours.
  • A salt is random data added to each password before hashing. Without salts, an attacker can pre-compute hashes for common passwords (a "rainbow table") and instantly find matches in your database. With salts, every user's hash is different even if they share the same password. All modern password hashing libraries handle salting automatically.

!!Never use MD5 or SHA1 for passwords: !!

Framework-specific implementations:

	
# Django (uses PBKDF2 with 216,000 iterations by default)
from django.contrib.auth.hashers import make_password, check_password

# Hash a password
hashed = make_password('user_password')
# Returns: 'pbkdf2_sha256$216000$...' (includes algorithm, iterations, salt, hash)

# Verify password
is_valid = check_password('user_password', hashed)
# Constant-time comparison built-in

# Flask with werkzeug (uses PBKDF2 by default)
from werkzeug.security import generate_password_hash, check_password_hash

hashed = generate_password_hash('user_password')
# Returns: 'pbkdf2:sha256:260000$...' (includes algorithm, iterations, salt)

is_valid = check_password_hash(hashed, 'user_password')

# FastAPI with passlib (bcrypt recommended)
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

hashed = pwd_context.hash('user_password')
# Returns: '$2b$12$...' (bcrypt with 12 rounds ~0.3 seconds)

is_valid = pwd_context.verify('user_password', hashed)

# Configure bcrypt work factor (higher = slower but more secure)
pwd_context = CryptContext(
    schemes=["bcrypt"],
    bcrypt__rounds=12  # Default; increase to 13-14 for higher security
)
  

Password requirements for 2026

  • Minimum 8 characters (12+ strongly recommended).
  • Use bcrypt, PBKDF2, or Argon2: Never MD5, SHA1, or plain SHA256. MD5 and SHA1 are fast cryptographic hashes designed for file integrity checking, not password storage. Because they're fast, attackers can compute billions of guesses per second. MD5 can test 180 billion password combinations per second on modern GPUs. Use bcrypt, PBKDF2, or Argon2 instead; these are deliberately slow (1,000-100,000 iterations) making brute-force attacks infeasible.
  • Automatic salting: Modern libraries handle this.
  • Rate limit login attempts: 5 attempts per hour per email.
  • Password strength meters: Client-side feedback helps users choose better passwords.
  • Account lockout: Temporarily lock accounts after 5-10 failed attempts.
  • No password complexity requirements: "MyPasswordIs12CharactersLong" is better than "P@ssw0rd!".
  • Check against known breaches: Use haveibeenpwned.com API to reject compromised passwords.

Common mistakes to avoid

  • Rolling your own password hashing algorithm.
  • Using MD5 or SHA1 for password storage.
  • Storing passwords in reversible encryption.
  • Not salting passwords (modern libraries do this automatically, but check if using old code).
  • Using the same salt for all passwords.
  • Lowering bcrypt work factor for "performance" (the slowness is the security).
  • Storing password hints that reveal the password.

Authentication implementation patterns

Let's explore authentication implementation across all three frameworks.

Pattern 1: Middleware-based authentication

This is the most common authentication pattern. Middleware (or decorators) checks authentication before your route handler executes, providing a clean separation between authentication logic and business logic. If the user isn't authenticated, they're redirected to login or receive a 401 response and your route handler never runs.

This pattern works well for protecting entire sections of your application. You add a decorator to each route that needs authentication, and the framework handles session validation automatically. The authenticated user object becomes available throughout your request handling code.

Django: Automatic, session-based, user object everywhere. Django's authentication middleware runs on every request, automatically populating request.user with the authenticated user (or AnonymousUser if not logged in). You get authentication "for free" with minimal configuration, just enable the middleware and you're ready. The @login_required decorator is built into Django and works seamlessly with the framework's session system.

	
# Django uses built-in middleware
# settings.py
MIDDLEWARE = [
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
]

# views.py
from django.contrib.auth.decorators import login_required

@login_required
def protected_view(request):
    return JsonResponse({
        'message': f'Hello, {request.user.username}',
        'email': request.user.email
    })
  

Flask: Extension-based, requires setup. Flask's philosophy is "bring your own batteries," so authentication isn't built-in. You'll install Flask-Login (or Flask-Security-Too for more features) and configure it yourself. This gives you flexibility to customize the authentication flow, but requires more initial setup. You define how users are loaded from your database via the user_loader callback, and you choose where to store sessions (cookies, Redis, database).

	
# Flask requires Flask-Login extension
from flask import Flask, jsonify
from flask_login import LoginManager, login_required, current_user

app = Flask(__name__)
login_manager = LoginManager()
login_manager.init_app(app)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(user_id)

@app.route('/protected')
@login_required
def protected():
    return jsonify({
        'message': f'Hello, {current_user.username}',
        'email': current_user.email
    })
  

FastAPI: Dependency injection, stateless, type-safe. FastAPI uses its dependency injection system (Depends()) to handle authentication. This is more explicit than Django—you declare exactly which routes need authentication and what user data they receive. It's typically stateless (using JWTs), which means no server-side session storage. The type system ensures that if your route expects an authenticated user, Python's type checker will catch errors at development time, not in production.

	
# FastAPI uses dependency injection
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBearer
from jose import JWTError, jwt

app = FastAPI()
security = HTTPBearer()

def verify_token(credentials = Depends(security)) -> dict:
    token = credentials.credentials
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401)
        return payload
    except JWTError:
        raise HTTPException(status_code=401)

@app.get("/protected")
async def protected(current_user: dict = Depends(verify_token)):
    return {
        "message": f"Hello, {current_user['username']}",
        "user_id": current_user['sub']
    }
  

Pattern 2: Permission-based authorization

While Pattern 1 checks if a user is authenticated, this pattern checks what they're allowed to do. Permission-based authorization implements role-based access control (RBAC) where users have specific capabilities like "can_edit_posts" or "can_delete_users".

This pattern is essential for applications with different user roles (admin, moderator, regular user) where being logged in isn't enough—you need the right permissions to perform certain actions. It combines authentication (Layer 1) with capability checking (Layer 2), and often includes object-level checks (Layer 3) to ensure users only modify resources they own.

Django:

	
from django.contrib.auth.decorators import permission_required

@login_required
@permission_required('app.can_edit_posts', raise_exception=True)
def edit_post(request, post_id):
    post = Post.objects.get(id=post_id)
    if post.author != request.user and not request.user.is_staff:
        raise PermissionDenied
    return JsonResponse({'status': 'success'})
  

Flask:

	
from functools import wraps

def permission_required(permission):
    def decorator(f):
        @wraps(f)
        @login_required
        def decorated_function(*args, **kwargs):
            if not current_user.has_permission(permission):
                abort(403)
            return f(*args, **kwargs)
        return decorated_function
    return decorator

@app.route('/posts/<int:post_id>/edit', methods=['POST'])
@permission_required('edit_posts')
def edit_post(post_id):
    post = Post.query.get_or_404(post_id)
    if post.author_id != current_user.id:
        abort(403)
    return jsonify({'status': 'success'})
  

FastAPI:

	
from enum import Enum

class Permission(str, Enum):
    EDIT_POSTS = "edit_posts"

def require_permission(permission: Permission):
    def permission_checker(current_user = Depends(verify_token)):
        if permission.value not in current_user.get("permissions", []):
            raise HTTPException(status_code=403)
        return current_user
    return permission_checker

@app.post("/posts/{post_id}/edit")
async def edit_post(
    post_id: int,
    current_user = Depends(require_permission(Permission.EDIT_POSTS))
):
    post = await get_post(post_id)
    if post.author_id != current_user['sub']:
        raise HTTPException(status_code=403)
    return {"status": "success"}
  

Session management strategies

How you store and validate sessions is one of your most important architectural decisions. It affects security (can you immediately revoke sessions?), performance (how fast is validation?), scalability (can you add more servers?), and user experience (how often do users need to re-login?).

The three main strategies each offer different tradeoffs. JWT sessions are stateless and fast but can't be immediately revoked. Database sessions offer complete control but require a database query per request. Redis sessions provide the best balance (fast like JWTs with revocation like databases) but add infrastructure complexity.

Choose based on your application's priorities: startup MVPs often start with JWTs for simplicity, growing applications move to Redis for better control, and enterprise applications may use database sessions for maximum auditability.

Strategy 1: JWT

JSON Web Tokens (JWTs) are self-contained tokens that carry all necessary user information within the token itself. When a user logs in, the server signs a JWT containing their user ID and other claims, then sends it to the client. On subsequent requests, the client includes this token, and the server validates the signature (no database lookup required).

This stateless approach is powerful: you can validate tokens without any shared state between servers, making horizontal scaling trivial. Add 10 more servers and they all validate tokens independently using the same secret key. This is why JWTs are popular for APIs, microservices architectures, and serverless deployments where maintaining shared session state is complex.

However, the stateless nature is also JWT's biggest limitation. Once issued, a JWT remains valid until it expires. If you need to immediately log out a user (perhaps their account was compromised), you can't simply "delete" their session, the token will continue working until expiration. This is why most JWT implementations use short-lived access tokens (few minutes) paired with long-lived refresh tokens stored server-side.

When this makes sense: APIs serving mobile apps, SPAs, microservices, serverless/edge deployments, or any scenario where stateless authentication simplifies your architecture.

	
# Django with PyJWT
import jwt
from datetime import datetime, timedelta

def create_jwt(user_id):
    payload = {
        'user_id': user_id,
        'exp': datetime.utcnow() + timedelta(days=7),
        'iat': datetime.utcnow()
    }
    return jwt.encode(payload, SECRET_KEY, algorithm='HS256')

# Flask with Flask-JWT-Extended
from flask_jwt_extended import create_access_token, jwt_required

@app.route('/login', methods=['POST'])
def login():
    access_token = create_access_token(identity=user.id)
    return jsonify(access_token=access_token)

# FastAPI with python-jose
from jose import jwt

def create_access_token(data: dict):
    expire = datetime.utcnow() + timedelta(hours=24)
    to_encode = data.copy()
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm="HS256")
  

Pros:

  • No database queries (~5-10ms validation).
  • Scales horizontally without shared state.
  • Works with serverless/edge.
  • Simple implementation.

Cons:

  • Cannot revoke immediately.
  • Size limited (~4KB).
  • Requires refresh token strategy.
  • Less auditable.

Strategy 2: Database sessions

Database sessions store session data in your database (PostgreSQL, MySQL, etc.) with a random session ID sent to the client as a cookie. On each request, the server looks up this session ID in the database to retrieve the user's information. This gives you complete control over sessions; you can immediately revoke access by deleting the database row, making the session invalid instantly.

This control is crucial for certain applications. If a user reports their account as compromised, you can terminate all their sessions immediately. If you need detailed audit trails showing exactly when and where users logged in, database sessions make this straightforward. If you want to display a list of active sessions across devices ("You're logged in on iPhone, MacBook, and Windows PC"), database sessions provide the necessary data structure.

The trade-off is performance and infrastructure costs. Every authenticated request requires a database query (typically 30-100ms), though connection pooling can reduce this significantly. Your database becomes a critical component of request handling, not just data storage. For high-traffic applications, this can become expensive, both in database costs and latency.

When this makes sense: Applications requiring immediate revocation (banking, admin panels), detailed audit trails for compliance, multi-device session management, or when you need tight control over session lifecycle.

	
# Django (uses database by default)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'

# Flask with Flask-Session
from flask_session import Session
app.config['SESSION_TYPE'] = 'sqlalchemy'
Session(app)

# FastAPI custom
from sqlalchemy import Column, String, DateTime

class Session(Base):
    __tablename__ = "sessions"
    id = Column(String, primary_key=True)
    user_id = Column(Integer)
    expires_at = Column(DateTime)

async def create_session(user_id: int) -> str:
    session_id = secrets.token_urlsafe(32)
    session = Session(
        id=session_id,
        user_id=user_id,
        expires_at=datetime.utcnow() + timedelta(days=7)
    )
    db.add(session)
    await db.commit()
    return session_id
  

Pros:

  • Immediate revocation.
  • Detailed audit trails.
  • Unlimited session data.
  • Multi-device management.

Cons:

  • Database query per request (~30-100ms).
  • Requires connection pooling.
  • Higher infrastructure costs.

Strategy 3: Redis sessions

Redis sessions combine the best aspects of both previous strategies: the performance of JWTs with the control of database sessions. Redis is an in-memory data store, so session lookups are extremely fast (5-20ms compared to 30-100ms for databases). Like database sessions, you can immediately revoke sessions by deleting the Redis key: instant logout across all devices.

Redis excels at session storage because it's designed for exactly this use case: fast key-value lookups with automatic expiration (TTL). When you create a session, you set it to expire in 7 days, and Redis automatically deletes it, no cleanup jobs needed. This keeps your session store lean without manual maintenance.

The infrastructure is more complex than JWTs (you need to run Redis servers) but simpler than you might expect. Redis is stable, well-documented, and most hosting platforms offer managed Redis with automatic backups and failover. For applications outgrowing JWTs but finding database sessions too slow, Redis is often the natural next step.

The main consideration is that Redis stores data in memory, so you need sufficient RAM for your session count. As a rough estimate, 10,000 concurrent sessions typically require about 10-50MB of memory depending on what you store. Most applications find this overhead acceptable given the performance benefits.

When this makes sense: Production applications needing session revocation with JWT-like performance, growing applications moving beyond stateless JWTs, or any application where 5-20ms session lookups with immediate revocation is the sweet spot between performance and control.

	
# Django with Redis
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379',
    }
}

# Flask with Redis
from flask_session import Session
import redis
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.from_url('redis://localhost:6379')
Session(app)

# FastAPI with Redis
from redis import asyncio as aioredis
redis_client = await aioredis.from_url("redis://localhost")

async def create_session(user_id: int) -> str:
    session_id = secrets.token_urlsafe(32)
    session_data = {"user_id": user_id}
    await redis_client.setex(
        f"session:{session_id}",
        604800,  # 7 days
        json.dumps(session_data)
    )
    return session_id
  

Pros:

  • Fast lookups (~5-20ms).
  • Immediate revocation.
  • TTL-based auto-cleanup.
  • Horizontal scaling.

Cons:

  • Additional infrastructure (Redis).
  • More complex than JWTs.
  • In-memory (requires backups).

Performance optimization

Python authentication performance directly impacts your API response times and user experience. A slow authentication check adds latency to every protected endpoint, and under load, inefficient authentication can become your application's bottleneck. The good news is that with the right architectural choices, authentication overhead can be kept to 5-20ms even at high scale.

Async vs sync

Synchronous authentication works perfectly fine for many applications. If you're using JWT validation, the entire process happens in memory with cryptographic operations that complete in 5-10ms. There's no I/O wait that async could optimize. Similarly, if your application handles moderate traffic (under 50 concurrent requests) and uses database sessions with proper connection pooling, the overhead of async (more complex code, harder debugging) outweighs the benefits. Sync is the default choice. It's simpler to write, easier to debug, and Python's ecosystem has decades of mature synchronous libraries. Unless you have a specific reason to use async, sync will serve you well.

Async becomes powerful when you're waiting on I/O, specifically, waiting on external systems. OAuth flows that call external identity providers, SSO integrations that validate tokens with corporate IdPs, or authentication systems that make multiple external API calls per request all benefit enormously from async.

With sync code, if each authentication check calls an external API that takes 200ms, your server can handle 5 requests per second per worker (1000ms / 200ms = 5). With async, that same worker can handle dozens of concurrent requests while waiting for those API calls to complete. The 200ms wait time doesn't change, but your server's throughput increases dramatically.

Here's a concrete example showing async's power:

	
# FastAPI async authentication
async def get_current_user(
    session: AsyncSession = Depends(get_db),
    token: str = Depends(oauth2_scheme)
):
    # Async database query doesn't block the worker
    user_id = await verify_token_async(token)
    user = await session.execute(
        select(User).filter(User.id == user_id)
    )
    return user.scalar_one_or_none()

@app.get("/dashboard")
async def dashboard(user: User = Depends(get_current_user)):
    # Multiple async calls run concurrently instead of sequentially
    # Sync: 50ms + 50ms + 50ms = 150ms total
    # Async: max(50ms, 50ms, 50ms) = 50ms total
    profile, posts, notifications = await asyncio.gather(
        get_user_profile(user.id),
        get_user_posts(user.id),
        get_notifications(user.id)
    )
    return {"profile": profile, "posts": posts, "notifications": notifications}
  

Database connection pooling

If you're using database sessions, connection pooling is essential. Without pooling, every authentication check opens a new database connection (50-100ms overhead), performs the query (~30-50ms), then closes the connection. With pooling, you maintain a pool of open connections and reuse them, reducing overhead to under 5ms.

The performance difference is dramatic: an application doing 100 authentication checks per second without pooling spends 5-10 seconds just opening and closing connections. With pooling, that overhead drops to under 0.5 seconds; a 10-20x improvement.

How connection pooling works:

When your application starts, the pool creates a set number of connections to the database and keeps them open. When a request needs to authenticate, it borrows a connection from the pool, uses it for the query, then returns it. The next request reuses that same connection; no setup or teardown cost.

	
# Django - Enable persistent connections
DATABASES = {
    'default': {
        'CONN_MAX_AGE': 600,  # Keep connections alive for 10 minutes
        # After 10 minutes of inactivity, Django closes and recreates the connection
    }
}

# Flask with SQLAlchemy - Full pooling control
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 10,        # Maintain 10 open connections
    'max_overflow': 20,     # Allow 20 additional connections under high load
    'pool_timeout': 30,     # Wait up to 30s for available connection
    'pool_recycle': 3600,   # Recycle connections every hour (prevents stale connections)
    'pool_pre_ping': True,  # Verify connection is alive before using it
}

# FastAPI with async SQLAlchemy
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    pool_size=10,           # Base pool size
    max_overflow=20,        # Additional connections under load
    pool_recycle=3600,      # Recycle connections hourly
)
  

Impact:

  • Without pooling: 50-100ms connection overhead per request.
  • With pooling: <5ms to get connection from pool.
  • Reduces database CPU load by 10-20x (no constant connecting/disconnecting).
  • Prevents connection exhaustion under load.

How to size your pool:

  1. Start with pool_size=10 and max_overflow=20.
  2. Monitor your application under load.
  3. If you see "connection pool exhausted" errors, increase pool_size.
  4. If your database shows too many idle connections, decrease it.

A good rule of thumb: pool_size = (2 × number_of_cores) + number_of_disk_spindles, but actual needs vary based on your workload.

Rate limiting

Rate limiting protects your authentication endpoints from brute-force attacks and abuse. Without rate limiting, an attacker can make thousands of login attempts per second, either guessing passwords or causing a denial-of-service.

Implementing rate limiting is straightforward with the right libraries, and it's one of the highest-value security measures you can add:

	
# Django with django-ratelimit
from django_ratelimit.decorators import ratelimit

@ratelimit(key='ip', rate='5/h', method='POST')
def login_view(request):
    # Limit login attempts to 5 per hour per IP address
    # After 5 attempts, returns 429 Too Many Requests
    pass

# Flask with Flask-Limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(app, key_func=get_remote_address)

@app.route('/login', methods=['POST'])
@limiter.limit("5 per hour")
def login():
    # Same protection: 5 attempts per hour per IP
    pass

# FastAPI with slowapi
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@app.post("/login")
@limiter.limit("5/hour")
async def login(request: Request):
    pass
  

Rate limiting strategies:

  • Per IP address: Simplest approach, but can affect legitimate users behind shared IPs (corporate networks, VPNs).
  • Per email address: More precise, but requires extracting email from the request body before rate limiting.
  • Sliding window: Count attempts in the last hour, not just within the current hour boundary.
  • Exponential backoff: After failed attempts, increase the delay before allowing the next attempt (1s, 2s, 4s, 8s...).

Considerations for production:

  • For high-traffic applications, store rate limit counters in Redis rather than in-memory. This ensures rate limits work correctly across multiple servers.
  • Consider implementing different limits for different user types (stricter for anonymous users, more lenient for authenticated users making API calls).

Building authentication from scratch

Building authentication yourself gives you complete control and eliminates vendor dependencies. But "authentication" isn't a single feature, it's an entire subsystem of interconnected components, each with its own security considerations and edge cases.

Let's walk through what you're actually signing up for when you decide to build auth in-house.

The core authentication system

At minimum, you need:

  • User registration:
    • Password hashing (bcrypt or Argon2; never store plain text).
    • Email validation and uniqueness checks.
    • Password strength requirements (length, complexity).
    • Protection against enumeration attacks (don't reveal if email exists).
    • Rate limiting on registration endpoint (prevent spam).
  • Login flow:
    • Secure password verification (constant-time comparison).
    • Session creation after successful login.
    • Failed login tracking (lock accounts after N attempts).
    • Rate limiting by IP and by email.
    • Login audit logs (IP, device, timestamp, success/failure).
  • Session management:
    • Session creation with secure random IDs.
    • Session storage (JWT, database, or Redis).
    • Session validation on every request.
    • Session refresh/renewal logic.
    • Logout (session deletion).

This is the absolute minimum before anyone can even log in.

Email verification

Most applications need to verify email addresses:

  • Generate unique, cryptographically secure verification tokens.
  • Store tokens with expiration (typically 24 hours).
  • Send verification emails.
  • Handle verification link clicks.
  • Resend verification emails (with rate limiting).
  • Update user status from "unverified" to "verified".
  • Decide what unverified users can/cannot do.

The hidden complexity here is that mail delivery is unreliable. You need retry logic, bounce handling, and spam folder considerations. Plus, verification tokens need to be long enough to be unguessable (128+ bits) but work in email links.

Password reset

Users forget passwords. You need:

  • Password reset request flow (rate limited).
  • Generate secure reset tokens (different from verification tokens). They must be cryptographically random, expire quickly (1-2 hours), and be single-use. You also need to prevent token enumeration (attacker trying tokens to find valid ones).
  • Send reset emails.
  • Validate reset tokens (check expiration, single-use).
  • Password reset form with strength validation.
  • Invalidate old sessions after password change.
  • Notify user via email that password was changed (security).

Multi-Factor Authentication (MFA)

Enterprise customers expect MFA. You'll need:

  • TOTP (Time-based One-Time Passwords):
    • Generate QR codes for authenticator apps.
    • Verify 6-digit codes with time-window tolerance.
    • Recovery codes (for when users lose their device).
    • Enforce MFA for certain roles or actions.
    • Remember trusted devices (optional).
  • SMS/Email codes:
    • Send time-limited codes via SMS or email.
    • Code verification with attempt limiting.
    • Cost management (SMS isn't free).
    • Handle delivery failures.
  • WebAuthn/Passkeys:
    • Most secure but most complex to implement.
    • Browser compatibility issues.
    • Key storage and management.
    • Fallback methods when devices are lost.

OAuth/Social login

If you want "Sign in with Google/GitHub/Microsoft":

  • Register OAuth applications with each provider.
  • Implement OAuth 2.0 flow (redirect, callback, token exchange).
  • Handle state parameter to prevent CSRF.
  • Map provider user IDs to your user accounts.
  • Account linking (user has both email and social login).
  • Handle provider-specific quirks and errors.
  • Keep up with provider API changes.

Each provider is different. Google's OAuth works differently than GitHub's. Some return email, some don't. Some verify emails, some don't. You need provider-specific code for each one.

Account management

Users need to manage their accounts:

  • Change email (with verification of new email).
  • Change password (with current password verification).
  • View active sessions and terminate them.
  • Enable/disable MFA.
  • Manage recovery codes.
  • Delete account (with confirmation).
  • Export user data (GDPR compliance).

Security features

Beyond basic authentication:

  • Rate limiting:
    • Login attempts per email (5 per hour).
    • Registration per IP (3 per day).
    • Password reset requests (3 per hour).
    • MFA code attempts (5 per device).
  • Anomaly detection:
    • Login from new country/IP.
    • Login from new device.
    • Multiple failed login attempts.
    • Concurrent sessions from different locations.
  • Audit logging:
    • Every authentication event.
    • Account changes.
    • Permission changes.
    • Data access (for compliance).
  • Bot protection:

Edge cases and corner cases

The devil is in the details:

  • What happens if a user changes their email while a verification email is in flight?
  • How do you handle password reset requests when the user's email is compromised?
  • What if a user signs up with OAuth, then tries to set a password?
  • How do you merge accounts if a user signs up twice with different emails?
  • What happens to active sessions when a user changes their password?
  • How do you handle timezones in MFA TOTP codes?
  • What's the user experience when MFA codes aren't working?

Each of these requires thoughtful handling and UI/UX consideration.

Infrastructure and operations

Beyond the code:

  • Email infrastructure:
    • Transactional email service (SendGrid, Postmark, AWS SES).
    • Email templates for verification, reset, alerts.
    • Bounce and complaint handling.
    • SPF, DKIM, DMARC configuration.
    • Spam filter considerations.
  • Monitoring and alerting:
    • Failed login spike detection.
    • Session creation anomalies.
    • Email delivery failures.
    • Rate limit violations.
    • Error rates and latency.
  • Compliance:
    • GDPR (data export, deletion, consent).
    • SOC 2 (if you want enterprise customers).
    • Password policy enforcement.
    • Data breach notification procedures.

Framework-specific considerations

Each framework has different strengths and ecosystems when building authentication from scratch. Understanding these differences helps you make architectural decisions early that are hard to change later.

Django:

  • Start with built-in auth system: Django's django.contrib.auth provides User models, authentication backends, password hashing, permissions, and groups out of the box. Don't reinvent these unless you have specific requirements. Build on top of Django's auth foundation rather than replacing it.
  • Customize User model early: If you need custom user fields (phone number, avatar, timezone), customize the User model before your first migration. Use AbstractUser (extends Django's User with your fields) or AbstractBaseUser (build from scratch). Changing this later requires complex data migrations.
  • Use permission and group system: Django's built-in permissions (user.has_perm('app.can_edit')) and groups integrate seamlessly with the admin and your views. Start with these before considering third-party permission libraries.
  • django-allauth for social auth: The de facto standard for OAuth/social login in Django. Handles Google, GitHub, Microsoft, and 50+ providers with a consistent interface. Mature, well-documented, and actively maintained.
  • django-otp for MFA: Adds TOTP (authenticator apps), SMS, and backup codes. Integrates with Django's auth system, admin, and user model. More comprehensive than building MFA yourself.

Flask:

  • Choose extensions early: Flask-Login (simple, just authentication) or Flask-Security-Too (authentication + role-based access + more features). This decision affects your entire auth architecture. Flask-Security-Too is more opinionated but gives you more out-of-the-box. Flask-Login is more minimal and flexible.
  • Select ORM early: SQLAlchemy is the standard choice and integrates with most Flask auth extensions. Peewee is lighter but has less ecosystem support. Your ORM choice affects how you store users, sessions, and permissions.
  • Flask-Session for storage: By default Flask stores sessions in signed cookies (stateless). For server-side sessions (database, Redis), use Flask-Session. Choose this early—migrating session storage later invalidates all user sessions.
  • authlib for social auth: Modern, actively maintained OAuth client. Handles OAuth 1.0, OAuth 2.0, and OpenID Connect. More flexible than older alternatives like Flask-OAuthlib (deprecated).
  • pyotp for MFA: Pure Python TOTP implementation for two-factor authentication. You'll build the UI and database storage yourself, but pyotp handles the cryptography correctly.

FastAPI:

  • Build on python-jose for JWTs: FastAPI has no built-in session system, so you'll likely use JWTs. python-jose handles JWT encoding/decoding with proper signature verification. Alternative: PyJWT is simpler but less feature-complete.
  • Use passlib for passwords: Passlib provides bcrypt, PBKDF2, and Argon2 hashing with a simple interface. Configure it once and it handles algorithm upgrades automatically as standards evolve.
  • OAuth2 with fastapi.security: FastAPI includes OAuth2 helpers in fastapi.security that generate correct OpenAPI documentation and integrate with the dependency injection system. Use these rather than implementing OAuth2 from scratch.
  • SQLAlchemy for database: While FastAPI is ORM-agnostic, SQLAlchemy (with async support via asyncpg or aiosqlite) is the most common choice. Alternatively, use tortoise-orm for async-native ORM or encode/databases for lower-level database access.
  • External email service: Unlike Django (built-in email) or Flask (Flask-Mail), FastAPI has no email functionality. You'll need to integrate SendGrid, Amazon SES, Mailgun, or similar. Factor this into your architecture early.

The time investment

Realistic estimates for building this yourself:

  • MVP (email/password only): 3-6 weeks
  • Production-ready (with MFA, OAuth): 2-3+ months
  • Enterprise-grade (SSO, compliance): 6+ months
  • Ongoing maintenance: ~25% of initial time annually

This doesn't include the opportunity cost: the features you're not building while you're building auth.

When building makes sense

You should consider building authentication yourself when:

  • You have unique authentication requirements that no provider supports.
  • You're building a platform where authentication is your product.
  • You have a team with deep security expertise.
  • You have months to invest in getting it right.

For most applications, especially B2B SaaS, a managed solution is the pragmatic choice. The question isn't whether you can build it yourself; you can. The question is whether building authentication is the best use of your engineering time. More often than not, the answer is no.

The case for managed authentication

While building authentication yourself provides maximum control, managed authentication providers offer significant advantages:

  • Time to production: Building authentication is complex. A managed provider eliminates weeks of development time, letting you focus on your core product. With the right provider you can add authentication to your app within hours, and have all the other flows like MFA, resets, etc up and running as well. Estimated time to market:
    • DIY (Django): 2-4 weeks MVP minimum.
    • DIY (Flask): 3-6 weeks MVP minimum.
    • DIY (FastAPI): 3-6 weeks MVP minimum.
    • Managed provider: 1-3 hours to production.
  • Security maintenance: Authentication security requires constant vigilance: monitoring for new vulnerabilities, updating dependencies, implementing new security standards, responding to security incidents, performing security audits, etc. Managed providers employ security teams dedicated to these tasks, often discovering and patching vulnerabilities before they become public knowledge.
  • Compliance requirements: Enterprise customers often require GDPR compliance, SOC 2 Type II certification, HIPAA compliance (for healthcare), and more. Building and maintaining compliance yourself can cost $50,000-$200,000+ annually in audits alone.
  • Feature richness: Modern authentication needs include many features that managed providers offer out of the box, tested and production-ready:
    • Email/password authentication.
    • OAuth/social login (Google, GitHub, Microsoft, etc.).
    • Multi-factor authentication.
    • Single Sign-On (SSO) for enterprise.
    • Directory sync (automatic user provisioning).
    • Session management across devices.
    • Bot detection.
    • Passwordless authentication.
    • Organization/team management.

WorkOS: Enterprise ready authentication

WorkOS takes a different approach to authentication compared to traditional providers. Rather than being purely an authentication service, WorkOS is a platform that enables B2B SaaS companies to ship enterprise features quickly. This matters because you can start with just authentication and add enterprise capabilities like SSO and Directory Sync later without rearchitecting your application.

Why WorkOS stands out

1. Generous free tier

WorkOS offers free authentication for up to 1 million monthly active users. This is significantly more generous than other providers:

  • Clerk: Free up to 10,000 MAU, then $0.02 per user.
  • Auth0: Free up to 7,000 MAU, then $35+ per month.
  • Supabase: Free up to 50,000 MAU.

For products with large user bases or freemium models, WorkOS's pricing is substantially more cost-effective. You can use the pricing calculator to calculate exactly how much you will have to pay. No hidden fees.

2. Python-first SDK

WorkOS provides a dedicated Python SDK working across all frameworks:

	
# Install
pip install workos

# Initialize (works with all frameworks)
from workos import WorkOSClient

workos = WorkOSClient(
    api_key=os.getenv("WORKOS_API_KEY"), client_id=os.getenv("WORKOS_CLIENT_ID")
)

# Use the decorator in the route that should only be accessible to logged in users.
@app.route("/dashboard")
@with_auth
def dashboard():
    session = workos.user_management.load_sealed_session(
        sealed_session=request.cookies.get("wos_session"),
        cookie_password=cookie_password,
    )
    response = session.authenticate()
    current_user = response.user if response.authenticated else None
    print(f"User {current_user.first_name} is logged in")
    # Render a dashboard view
  

The SDK handles the complexity of authentication so you can focus on building features. For more details on how to add auth using the SDK check out the quickstart (or run npx workos@latest and have the AI installer add it to your Python app automatically).

3. Complete feature set from day one

WorkOS provides a comprehensive platform that goes beyond basic authentication:

  • Flexible UI support via APIs and SDKs, with AuthKit as a highly customizable hosted login powered by Radix.
  • Multiple authentication methods, each one enabled in minutes:
  • Sessions model with access + refresh tokens and guidance for secure cookie storage. Automatic token refresh and secure cookies.
  • Enterprise SSO with native SAML and OIDC, configurable by customers through an Admin Portal.
  • SCIM provisioning: Automated user provisioning and deprovisioning that enterprises expect, handling the "remove this employee immediately" requests that inevitably arrive. Real-time synchronization with any identity provider (Okta, Azure AD, Google Workspace, and more).
  • Tamper-proof audit logs for SOC 2, HIPAA, and GDPR.
  • Secure session handling with server-side validation and instant session revocation capabilities.
  • Customizable JWT claims: Add custom data to JWT payloads with JWT templates for flexible token customization.
  • Radar for suspicious login detection and threat monitoring that alerts you to potential account compromises.
  • Fine-grained authorization: Role-based access control with customizable permissions.
  • Feature flags: Integrated feature flagging for gradual rollouts.
  • First-class multi-tenancy with organization management, member invitations, and role assignment.
  • Enterprise SLA and dedicated support.
  • Domain verification: Prove ownership of email domains. Enable domain-based routing where users from acme.com auto-route to Acme's SSO.
  • Vault: Store sensitive customer data securely with customer-managed encryption keys for compliance and data residency requirements.
  • Webhooks: Real-time event notifications for user lifecycle, SSO, and Directory Sync events. Automatic retry logic with exponential backoff and secure webhook verification.
  • Feature Flags: Control feature rollout to specific users or organizations. A/B testing capabilities and gradual rollout strategies.
  • And more.

4. Platform approach

The key differentiator is how these products work together. When you use WorkOS:

  • Users provisioned through Directory Sync automatically work with AuthKit authentication.
  • SSO sessions integrate seamlessly with your existing auth flow.
  • Audit Logs capture all authentication and authorization events.
  • Admin Portal allows customers to self-configure enterprise features.
  • Feature flags information is included in every JWT so you know which users should have access to this new feature you're launching.
  • And more.

You're not stitching together separate systems, it's one cohesive platform where features compound on each other.

5. Migration path

Start simple and add complexity as you grow:

  1. MVP stage: Just AuthKit for email/password authentication.
  2. Growth stage: Add social login and MFA.
  3. Enterprise stage: Enable SSO for enterprise customers.
  4. Scale stage: Add Directory Sync for automatic provisioning.

Each stage builds on the previous one without rearchitecting. The authentication system you build on day one scales to enterprise without major refactoring.

6. Developer experience

WorkOS prioritizes developer experience at every level:

  • Quick setup: Complete integration in 1-2 hours.
  • Excellent documentation: Clear guides, API references, and working examples for every feature.
  • CLI installer: Automated setup that detects your framework and configures everything.
  • Example apps: Production-ready reference implementations for Python and other frameworks.
  • Generous free tier: Build and test without worrying about costs (up to 1M MAU).
  • No vendor lock-in: Standard protocols (OAuth, SAML, SCIM) make migration possible if needed.
  • Responsive support: Active Discord community and responsive support team.
  • Transparent changelog: Clear communication about new features, breaking changes, and deprecations.

Production best practices

Security checklist

  • Update Python and frameworks to latest stable versions.
  • Never use pickle for user data: Use JSON or signed tokens.
  • Validate all inputs with Pydantic, Marshmallow, or Django forms.
  • Use parameterized queries: Never string format SQL.
  • Implement constant-time comparison: Use secrets.compare_digest.
  • Use yaml.safe_load instead of yaml.load.
  • Configure secure cookies: HTTPOnly, Secure, SameSite.
  • Hash passwords properly: bcrypt/Argon2, never MD5.
  • Implement rate limiting on auth endpoints.
  • Enable CSRF protection for state-changing operations.
  • Use HTTPS only in production.
  • Implement defense-in-depth: Multiple auth layers.
  • Monitor for anomalies: New locations, devices, failed attempts.
  • Keep dependencies updated: Use pip-audit.
  • Implement proper logging: Auth events, errors, security incidents.

Performance checklist

  • Use connection pooling for database sessions.
  • Consider async for high-concurrency.
  • Implement caching for expensive lookups.
  • Choose appropriate session storage (JWT vs Redis vs Database).
  • Monitor auth latency at p50, p95, p99.
  • Use Redis for session storage in production.
  • Optimize database queries with proper indexes.
  • Monitor infrastructure: CPU, memory, connections.

Framework-specific best practices

Django:

  • Customize User model early (AbstractBaseUser or AbstractUser).
  • Enable Django's security middleware.
  • Set SECURE_SSL_REDIRECT = True in production.
  • Use Django's permission system consistently.
  • Use cached sessions (cached_db) for performance.
  • Enable CONN_MAX_AGE for connection pooling.

Flask:

  • Always set SECRET_KEY securely (not hardcoded).
  • Use Flask-Login for session management.
  • Implement CSRF protection with Flask-WTF.
  • Use `Flask-Limiter` for rate limiting.
  • Configure secure session cookies.
  • Use Flask-Session for server-side sessions.
  • Validate inputs with WTForms or Marshmallow.

FastAPI:

  • Use Pydantic models for all input validation.
  • Implement OAuth2 with proper JWT tokens.
  • Use dependencies (Depends) for auth consistently.
  • Implement proper exception handling.
  • Use async database libraries when possible.
  • Enable CORS properly for APIs.
  • Document auth requirements in OpenAPI.

Deployment checklist

  • Use HTTPS: SSL/TLS certificates.
  • Set environment variables: Never commit secrets.
  • Use a process manager: Gunicorn, Uvicorn.
  • Configure web server: Nginx or Apache reverse proxy.
  • Enable monitoring: APM tools.
  • Set up logging: Centralized logging.
  • Implement backups: Database and session storage.
  • Use a firewall: Limit ports.
  • Configure rate limiting: Web server and application level.
  • Test disaster recovery: Practice restoring from backups.

Monitoring and observability

Implement comprehensive monitoring for authentication. Track key metrics:

  • Authentication success/failure rates.
  • Session creation/deletion rates.
  • Average authentication latency.
  • Failed login attempts per user.
  • Geographic distribution of auth requests.
  • Device and browser distribution.

Error handling

Implement proper error handling without information disclosure. Never expose:

  • Whether a user exists.
  • Password requirements not being met.
  • Internal system errors.
  • Stack traces.
  • Database errors.

Conclusion

Authentication in Python offers flexibility across three powerful frameworks, each with distinct advantages. Django provides comprehensive built-in authentication for full-stack B2B applications. Flask offers maximum flexibility for custom requirements. FastAPI delivers modern async-first architecture for high-performance APIs.

If you're building authentication yourself:

  • Budget 2-4 weeks minimum for MVP.
  • Plan for ongoing security maintenance.
  • Leverage framework-specific libraries.
  • Implement defense-in-depth.
  • Follow Python security best practices.

If you're considering a managed solution:

  • Evaluate based on your specific needs (speed, cost, features).
  • Consider long-term costs, not just initial pricing.
  • Verify framework compatibility.
  • Verify compliance requirements for your industry.

WorkOS provides a compelling option for teams that:

  • Need to ship enterprise features quickly.
  • Want generous free tier (up to 1M MAU).
  • Require SSO and Directory Sync.
  • Native Python SDK integration.
  • Value comprehensive platform features.

The authentication landscape in 2026 offers more choices than ever. Whether you build or buy, understanding the core concepts (session management, defense-in-depth security, framework patterns, and performance optimization) will help you make the right decision for your Python application.

Authentication is critical infrastructure. Invest the time to get it right, whether that means building it yourself with Python's excellent ecosystem, or choosing a partner like WorkOS that shares your commitment to security and developer experience.

Choose the authentication provider that matches where your application is headed, not just where it is today. Your future self (and your enterprise customers) will thank you.

Sign up for WorkOS today and secure your Python app.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.