TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Detect and Revoke Suspicious Login Sessions in a Python Web App
Learning Hub

How to Detect and Revoke Suspicious Login Sessions in a Python Web App

A hands-on FastAPI tutorial that builds the Active Sessions feature from scratch: device fingerprinting, new-device alerts, and one-click session revocation.

August 15, 2026 16 Min Read
47

TechCrunch published a guide this week telling ChatGPT, Claude, and Perplexity users how to check whether their account has been quietly taken over: open Settings, find “Active Sessions” or “Security and Login,” and look for a device you don’t recognize. It’s good advice. It’s also advice that only works because someone on the other end built the feature it depends on: a list of every place you’re currently logged in, with a button to kick out any one of them.

Table Of Content

  • What a “Session” Actually Is (and Why Attackers Want One)
  • What You’ll Build
  • Prerequisites
  • Step 1: Set Up the Project
  • Step 2: Generate and Store Session Identifiers Correctly
  • Step 3: Build the Login Endpoint That Creates a Session
  • The Gotcha: Your Own Test Client Will Silently Break This
  • Step 4: Require a Valid Session on Protected Routes
  • Step 5: List Active Sessions
  • Step 6: Detect and Flag New-Device Logins
  • Step 7: Revoke a Single Session
  • Step 8: Revoke Every Other Session
  • Step 9: Verify Expiration Actually Works
  • Idle Timeout, Live
  • Absolute Timeout, Without Waiting Six Hours
  • Common Mistakes and Gotchas
  • How to Confirm It All Works End to End
  • Next Steps

This tutorial builds that feature from scratch. You’ll write a small Python web app with FastAPI that tracks every login as its own session record, fingerprints the device behind it, flags logins from devices it hasn’t seen before, and lets you revoke one session or every session but the one you’re using right now. Along the way you’ll hit a real bug that silently breaks session cookies on almost every local FastAPI test setup, and fix it properly instead of papering over it.

By the end, you won’t just know how to click “Log out all other devices.” You’ll know exactly what happens on the server when you do.

What a “Session” Actually Is (and Why Attackers Want One)

When you log into a website, most applications don’t ask you to send your password on every single request. Instead, the server creates a session: a record that says “this identifier represents an already-authenticated user,” and hands your browser a token, usually stored in a cookie, that proves you own that session. Every request after that just needs to present the token. No password required.

That convenience is exactly what makes stolen sessions dangerous. If an attacker steals your session token, whether through malware, a leaked browser profile, or a poorly secured public computer, they don’t need your password at all. They already have the thing your password was protecting. Changing your password afterward doesn’t help, because the stolen session token is still valid until it expires or someone explicitly revokes it. That’s the entire reason “Active Sessions” pages exist: they give you a way to look at every valid session tied to your account and cut off the ones you didn’t create.

This is also why a page like that can’t be built on a plain JSON Web Token (JWT) the way an earlier tutorial on this site built one. A JWT is self-contained: the server verifies its signature and trusts whatever is inside, without checking anywhere else. There’s no central place to delete it from, short of maintaining a separate blocklist that defeats the point of using a stateless token in the first place. An “Active Sessions” feature needs the opposite design: an opaque, meaningless-on-its-own token that the server looks up in its own session store on every request. That’s what you’re about to build.

What You’ll Build

You’ll build a FastAPI application with:

  • A login endpoint that creates a session record with a securely generated identifier, hashed before it’s stored.
  • Device fingerprinting from the User-Agent header, so each session can show a human-readable label like “Chrome on Windows.”
  • New-device detection that flags (and, in a real deployment, would alert on) a login from a device your account hasn’t used before.
  • A GET /sessions endpoint: the exact feature TechCrunch is telling people to go click on.
  • Single-session revocation (“log out this device”) and bulk revocation (“log out everywhere else”).
  • Both sliding (idle) and absolute session expiration, with values grounded in OWASP’s published guidance.

Prerequisites

You’ll need:

  • Python 3.11 or newer (this tutorial was built and tested on Python 3.13.14).
  • pip and the ability to create a virtual environment.
  • Comfortable running commands in a terminal.
  • No prior FastAPI experience required; every piece of the framework used here is explained as it comes up.
  • Basic familiarity with what an HTTP cookie is helps, but isn’t assumed.

Step 1: Set Up the Project

Create a project folder, a virtual environment inside it, and install the four packages this tutorial uses: FastAPI and Uvicorn to serve the app, user-agents to parse browser strings into readable device labels, and requests to drive the live demo against the running server.

mkdir session-demo && cd session-demo
python -m venv .venv
.venv\Scripts\activate          # Windows
# source .venv/bin/activate     # macOS/Linux

pip install fastapi uvicorn user-agents requests

This tutorial pins nothing to exact versions beyond what’s listed, but for reference, it was built and verified against fastapi 0.141.1, uvicorn 0.52.3, user-agents 2.2.0, and requests 2.34.2.

Step 2: Generate and Store Session Identifiers Correctly

Create app.py and start with imports and password storage. This demo needs exactly one user to log in as, so it hardcodes one, hashed with PBKDF2-HMAC-SHA256 from Python’s standard library:

import hashlib
import hmac
import os
import secrets
import time
from typing import Annotated

from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, Response
from pydantic import BaseModel
from user_agents import parse as parse_user_agent

app = FastAPI()

# PBKDF2-HMAC-SHA256 at 600,000 iterations, OWASP's current recommended
# floor for PBKDF2 (OWASP's first-choice algorithm is Argon2id; PBKDF2 is
# used here only because it ships in the standard library, and this
# tutorial is about session lifecycle, not password hashing).
PBKDF2_ITERATIONS = 600_000


def hash_password(password: str, salt: bytes) -> bytes:
    return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)


_ALICE_SALT = bytes.fromhex("a3f5c9d1e7b2846f9c1d5e8a2b7f4c60")
_ALICE_PASSWORD_HASH = hash_password("correct-horse-battery-staple", _ALICE_SALT)

USERS = {
    "alice": {"salt": _ALICE_SALT, "password_hash": _ALICE_PASSWORD_HASH},
}


def verify_password(username: str, password: str) -> bool:
    user = USERS.get(username)
    if user is None:
        return False
    candidate = hash_password(password, user["salt"])
    return hmac.compare_digest(candidate, user["password_hash"])

The 600,000-iteration figure isn’t arbitrary. OWASP’s Password Storage Cheat Sheet lists “PBKDF2-HMAC-SHA256: 600,000 iterations” as its current recommendation for that specific algorithm, while naming Argon2id its overall first choice. If you’re building real password storage rather than a session-management demo, start with that cheat sheet, not this paragraph.

hmac.compare_digest matters here too: it compares the two hashes in constant time, so a timing attack can’t be used to guess the correct hash one byte at a time by measuring how long a plain == comparison takes to bail out on a mismatch.

Now add the session store itself:

class Session:
    __slots__ = ("session_id", "username", "device_label", "ip", "created_at", "last_seen_at")

    def __init__(self, session_id, username, device_label, ip, created_at, last_seen_at):
        self.session_id = session_id
        self.username = username
        self.device_label = device_label
        self.ip = ip
        self.created_at = created_at
        self.last_seen_at = last_seen_at


SESSIONS: dict[str, Session] = {}          # session_id (sha256 hex) -> Session
KNOWN_DEVICES: dict[str, set[str]] = {}    # username -> set of device fingerprint hashes seen before


def _session_id_for(raw_token: str) -> str:
    # Store only a hash of the token, the same way you'd store a
    # password-reset token. If the session table ever leaks (a database
    # backup, a misconfigured replica), the leaked rows can't be replayed
    # as working session cookies, because the raw token never touches disk.
    return hashlib.sha256(raw_token.encode()).hexdigest()

This tutorial uses a plain Python dictionary as the session store so the logic is easy to read top to bottom. In production, back this with Redis or a database table instead, both so sessions survive a restart and so they stay visible if you ever run more than one instance of the app.

Notice that SESSIONS is keyed by a SHA-256 hash of the token, never by the raw token itself. OWASP’s Session Management Cheat Sheet requires session identifiers to carry “at least 64 bits of entropy” generated by “a strong CSPRNG (Cryptographically Secure Pseudorandom Number Generator).” You’ll generate that token in the next step with secrets.token_urlsafe(32), which draws 32 random bytes, 256 bits, from the same operating-system CSPRNG that Python’s own secrets module documentation describes as “the most secure source of randomness that your operating system provides.” That’s four times the entropy OWASP requires as a floor. Hashing it before storage is a separate, complementary precaution: it’s the same reasoning you’d apply to a password-reset token, and it costs nothing at request time since you only ever need to hash the incoming cookie and look up the hash.

Step 3: Build the Login Endpoint That Creates a Session

Next, add a helper that turns a raw User-Agent string into a short, human-readable device label and a fingerprint hash, then the login endpoint itself:

def _device_fingerprint(ua_string: str) -> tuple[str, str]:
    ua = parse_user_agent(ua_string)
    label = f"{ua.browser.family} on {ua.os.family}"
    fingerprint = hashlib.sha256(ua_string.encode()).hexdigest()[:16]
    return fingerprint, label


class LoginRequest(BaseModel):
    username: str
    password: str


@app.post("/login")
def login(payload: LoginRequest, request: Request, response: Response):
    if not verify_password(payload.username, payload.password):
        raise HTTPException(status_code=401, detail="invalid username or password")

    raw_token = secrets.token_urlsafe(32)
    session_id = _session_id_for(raw_token)

    ua_string = request.headers.get("user-agent", "unknown")
    fingerprint, device_label = _device_fingerprint(ua_string)
    ip = request.client.host if request.client else "unknown"
    now = time.time()

    known = KNOWN_DEVICES.setdefault(payload.username, set())
    is_new_device = fingerprint not in known
    if is_new_device:
        known.add(fingerprint)
        # In production this is where you'd enqueue a real email or push
        # alert instead of printing a line.
        print(f"[SECURITY ALERT] new device login for {payload.username}: {device_label} from {ip}")

    SESSIONS[session_id] = Session(session_id, payload.username, device_label, ip, now, now)

    response.set_cookie(
        "session_token",
        raw_token,
        httponly=True,
        secure=True,
        samesite="lax",
    )
    return {"logged_in_as": payload.username, "new_device": is_new_device, "device": device_label}

Three cookie attributes are doing real security work here, and it’s worth knowing what each one actually stops, per MDN’s cookie documentation:

  • HttpOnly blocks JavaScript from reading the cookie via document.cookie, which means a cross-site scripting bug elsewhere on your site can’t be used to steal the session token directly.
  • Secure tells the browser to never send the cookie “with unsecured HTTP,” so it can’t be intercepted by a network attacker sitting between the user and a plain-HTTP page.
  • SameSite=Lax withholds the cookie on most cross-site requests, which cuts down on how easily another site can ride a logged-in user’s session for actions they didn’t intend.

The Gotcha: Your Own Test Client Will Silently Break This

Run this app right now with uvicorn app:app --port 8731 and try to log in from a Python script, and something strange happens: the login call succeeds and returns a 200, but the very next authenticated call comes back 401 not logged in, as if no cookie was ever sent.

Here’s what’s actually going on. MDN’s own documentation on the Secure attribute notes it’s “never sent with unsecured HTTP (except on localhost)”. That exception is real, but it’s a browser behavior. Python’s requests library, and FastAPI’s own recommended TestClient for writing tests, both go through http.cookiejar-style cookie handling that does not carry that same localhost exemption. A Secure cookie set by a plain-HTTP development server gets stored by the client, then silently withheld on every subsequent plain-HTTP request. It isn’t a bug in your code. It’s a real difference between how browsers and Python HTTP clients enforce the same RFC.

You can confirm this yourself. Log in with requests and inspect the cookie jar directly:

>>> import requests
>>> s = requests.Session()
>>> r = s.post("http://127.0.0.1:8731/login", json={"username": "alice", "password": "correct-horse-battery-staple"})
>>> list(s.cookies)
[Cookie(... name='session_token', ... secure=True, ...)]

The cookie is there, with secure=True recorded. It just never gets attached to the next plain-HTTP request. The fix isn’t to drop Secure from the cookie. That would leave a real deployment exposed. The fix is to make it configurable, default it to the secure, production-correct value, and only relax it for local testing:

# Defaults to True (correct for production, which must be served over
# HTTPS). Browsers exempt http://localhost from the Secure-attribute rule,
# but Python's requests/http.cookiejar does not carry that exemption, so a
# secure=True cookie set by a plain-HTTP dev server gets silently dropped
# by any Python client testing against it. Flip this off only for local
# runs against a plain-HTTP server, never in a real deployment.
COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() != "false"

Add that near the top of app.py, alongside the timeout constants from the next step, then change the set_cookie call in login() to use secure=COOKIE_SECURE instead of a hardcoded True. Run the server for local testing with SESSION_COOKIE_SECURE=false set, and leave it unset (or explicitly true) everywhere else. This exact snag hits FastAPI’s TestClient too, since it’s also built on an HTTP client library that respects the Secure flag literally, so the same environment variable fixes both your manual testing and your automated test suite.

Step 4: Require a Valid Session on Protected Routes

Now add the timeout constants and the dependency every protected route will use to validate the incoming cookie:

# OWASP's Session Management Cheat Sheet gives idle-timeout ranges of 2-5
# minutes for high-value applications and 15-30 minutes for low-risk
# applications, and an absolute-timeout range of 4-8 hours for an
# application used by an office worker for a full day. Defaults below sit
# at the permissive end of both ranges; override via env vars for testing.
IDLE_TIMEOUT_SECONDS = int(os.environ.get("SESSION_IDLE_TIMEOUT", "1800"))
ABSOLUTE_TIMEOUT_SECONDS = int(os.environ.get("SESSION_ABSOLUTE_TIMEOUT", str(6 * 3600)))


def get_current_session(session_token: Annotated[str | None, Cookie()] = None) -> Session:
    if session_token is None:
        raise HTTPException(status_code=401, detail="not logged in")

    session_id = _session_id_for(session_token)
    session = SESSIONS.get(session_id)
    if session is None:
        raise HTTPException(status_code=401, detail="session not found or already revoked")

    now = time.time()
    if now - session.created_at > ABSOLUTE_TIMEOUT_SECONDS:
        del SESSIONS[session_id]
        raise HTTPException(status_code=401, detail="session expired (absolute timeout)")
    if now - session.last_seen_at > IDLE_TIMEOUT_SECONDS:
        del SESSIONS[session_id]
        raise HTTPException(status_code=401, detail="session expired (idle timeout)")

    session.last_seen_at = now  # sliding expiration
    return session

These are two different clocks, and conflating them is a common mistake. Idle timeout resets every time the session is used, which is why last_seen_at is updated on every call: a user who’s actively working never gets logged out, no matter how long their overall session has run. Absolute timeout never resets. It’s measured from created_at, the moment the user logged in, and it fires eventually even if the user has been continuously active the entire time. Without an absolute timeout, a session token that leaks stays valid forever as long as someone, attacker included, keeps using it. OWASP’s guidance quoted above frames the trade-off directly: timeout values have to “balance security and usability, so that the user can comfortably complete the operations within the web application without the session frequently expiring.”

Step 5: List Active Sessions

This is the endpoint TechCrunch’s guide is telling AI platform users to go find in their account settings. Here, you’re building it:

@app.get("/sessions")
def list_sessions(current: Annotated[Session, Depends(get_current_session)]):
    return [
        {
            "id": s.session_id,
            "device": s.device_label,
            "ip": s.ip,
            "created_at": s.created_at,
            "last_seen_at": s.last_seen_at,
            "is_current": s.session_id == current.session_id,
        }
        for s in SESSIONS.values()
        if s.username == current.username
    ]

The Depends(get_current_session) part is FastAPI’s dependency injection at work: every request to this route runs the validation function from Step 4 first, and the route only executes if that function returns a valid Session instead of raising. The is_current flag matters for the UI this would eventually power: real “Active Sessions” pages always mark which row is the one you’re looking at right now, so you don’t accidentally revoke yourself while trying to clean up other devices.

Step 6: Detect and Flag New-Device Logins

The new-device detection logic is already in the login() function from Step 3: a per-user set of device fingerprints, checked and updated on every login. To see it work, start the server and log in from three different simulated “devices,” which in this demo just means three separate requests.Session() objects (each one its own cookie jar) sending different User-Agent headers:

uvicorn app:app --host 127.0.0.1 --port 8731
>>> laptop = requests.Session()
>>> laptop.post(url, json=creds, headers={"User-Agent": CHROME_WINDOWS}).json()
{'logged_in_as': 'alice', 'new_device': True, 'device': 'Chrome on Windows'}

>>> laptop_again = requests.Session()   # a second cookie jar, same browser UA
>>> laptop_again.post(url, json=creds, headers={"User-Agent": CHROME_WINDOWS}).json()
{'logged_in_as': 'alice', 'new_device': False, 'device': 'Chrome on Windows'}

>>> phone = requests.Session()
>>> phone.post(url, json=creds, headers={"User-Agent": SAFARI_IPHONE}).json()
{'logged_in_as': 'alice', 'new_device': True, 'device': 'Mobile Safari on iOS'}

That’s real output from running this exact code. The first Chrome/Windows login is flagged as new, because it’s the first time this account has ever used that fingerprint. The second Chrome/Windows login, from an entirely separate cookie jar simulating a second browser tab, is not flagged, because the fingerprint already exists in KNOWN_DEVICES. The Safari/iPhone login is flagged as new again, correctly.

Be honest with yourself about what this fingerprint actually measures: it’s a hash of the User-Agent string, nothing more. Two genuinely different physical laptops that happen to run the same browser and OS version will produce an identical fingerprint and won’t trigger a new-device alert for each other. That’s a real limitation, not an oversight. Production systems that need to tell physical devices apart layer a separate, longer-lived “remembered device” cookie on top of this kind of fingerprinting; User-Agent alone is a reasonable first signal, not a complete one.

The source IP is worth the same honesty. In this local demo, every request comes from 127.0.0.1, so IP address can’t distinguish anything here, which is exactly why device fingerprinting above is built entirely from the User-Agent and not the IP. In a real deployment sitting behind a reverse proxy or load balancer, you’d read the client’s real IP from a header like X-Forwarded-For, but only if your proxy is the one setting it. If you trust that header directly from the client instead, anyone can put any IP address they want in it and spoof their way past IP-based checks entirely.

Step 7: Revoke a Single Session

Now the part TechCrunch’s guide tells readers to actually use once they spot a device they don’t recognize: signing that one device out remotely.

@app.post("/sessions/{session_id}/revoke")
def revoke_session(session_id: str, current: Annotated[Session, Depends(get_current_session)]):
    target = SESSIONS.get(session_id)
    if target is None or target.username != current.username:
        raise HTTPException(status_code=404, detail="session not found")
    del SESSIONS[session_id]
    return {"revoked": session_id, "was_current_session": session_id == current.session_id}

The ownership check on the second line matters more than it might look: without target.username != current.username, any logged-in user could revoke any other user’s session just by guessing or enumerating session IDs. Continuing the live demo from Step 6, with the phone looking up one of the laptop’s session IDs from its own GET /sessions call and revoking it:

>>> phone.post(f"{base}/sessions/{laptop_session_id}/revoke").json()
{'revoked': '6d0bf884f6c25b8e261813c0986414c632d3f3fd01da039ea28a2d61152ef9bf', 'was_current_session': False}

>>> laptop.get(f"{base}/sessions")
<Response [401]>
{'detail': 'session not found or already revoked'}

That first laptop tab is locked out immediately, on its very next request, with no need to wait for any timeout.

Step 8: Revoke Every Other Session

The bulk version, “log out everywhere else,” is the one TechCrunch’s guide highlights for the case where you’re not sure which specific session is the intruder and just want everything but your current one gone:

@app.post("/sessions/revoke-others")
def revoke_other_sessions(current: Annotated[Session, Depends(get_current_session)]):
    to_delete = [
        sid for sid, s in SESSIONS.items()
        if s.username == current.username and sid != current.session_id
    ]
    for sid in to_delete:
        del SESSIONS[sid]
    return {"revoked_count": len(to_delete)}

Continuing the demo, with the surviving second laptop tab still logged in and the phone still active:

>>> phone.post(f"{base}/sessions/revoke-others").json()
{'revoked_count': 1}

>>> laptop_again.get(f"{base}/sessions")
<Response [401]>
{'detail': 'session not found or already revoked'}

>>> phone.get(f"{base}/sessions").json()
[{'id': '0cd47d161cc6e1c9fa63a21781fffde113b11fd77d19aa2d2f886af4e5f5dd2c', 'device': 'Mobile Safari on iOS', 'ip': '127.0.0.1', 'created_at': 1786823263.61, 'last_seen_at': 1786823263.63, 'is_current': True}]

One call, and every session but the phone’s own is gone. That’s the entire “something feels wrong, lock everything down” workflow TechCrunch is describing, built from two small endpoints and a set-difference.

Step 9: Verify Expiration Actually Works

Code that’s never been run under its own failure conditions is a guess, not a guarantee. Both timeout paths deserve a real test, not just a read-through.

Idle Timeout, Live

Idle timeout is short enough to demonstrate live. Restart the server with a deliberately short timeout, log in, wait past it, then try to use the session:

SESSION_IDLE_TIMEOUT=6 SESSION_COOKIE_SECURE=false uvicorn app:app --port 8731
>>> tablet = requests.Session()
>>> tablet.post(url, json=creds, headers={"User-Agent": CHROME_WINDOWS}).json()
{'logged_in_as': 'alice', 'new_device': False, 'device': 'Chrome on Windows'}

>>> time.sleep(8)
>>> tablet.get(f"{base}/sessions")
<Response [401]>
{'detail': 'session expired (idle timeout)'}

That’s a real 8-second wait against a real 6-second timeout, not a mocked clock. In production you’d set SESSION_IDLE_TIMEOUT somewhere in OWASP’s 900-1800 second range (15-30 minutes) instead, or down to 120-300 seconds (2-5 minutes) for a genuinely high-value application.

Absolute Timeout, Without Waiting Six Hours

Nobody is waiting 6 hours for a test to finish. Instead, prove the logic works by backdating a real session’s created_at field directly and confirming the dependency still rejects it, even though the session has been continuously active (a recent last_seen_at) the entire simulated time:

import time
from fastapi.testclient import TestClient
import app as app_module

def test_absolute_timeout_expires_even_when_active():
    client = TestClient(app_module.app)
    client.post("/login", json={"username": "alice", "password": "correct-horse-battery-staple"},
                 headers={"User-Agent": CHROME_WINDOWS})

    session_id = client.get("/sessions").json()[0]["id"]
    session = app_module.SESSIONS[session_id]
    session.created_at = time.time() - app_module.ABSOLUTE_TIMEOUT_SECONDS - 60
    session.last_seen_at = time.time()  # still "active" by every idle-timeout measure

    r = client.get("/sessions")
    assert r.status_code == 401
    assert r.json()["detail"] == "session expired (absolute timeout)"

Running this with pytest (with SESSION_COOKIE_SECURE=false set, for the same reason covered in Step 3, since TestClient hits the identical Secure-cookie snag) produces:

$ SESSION_COOKIE_SECURE=false pytest test_absolute_timeout.py -v -s

test_absolute_timeout.py::test_absolute_timeout_expires_even_when_active
[SECURITY ALERT] new device login for alice: Chrome on Windows from testclient
PASS: session active 1 second ago was still force-expired by absolute timeout
PASSED
============================== 1 passed ==============================

The session was updated one second before the check (last_seen_at = time.time()) and still got force-expired, which is exactly the behavior that stops a continuously-active leaked session from staying valid indefinitely. You may see a StarletteDeprecationWarning about httpx being deprecated in favor of a package called httpx2 when you run this; it’s a heads-up about a future dependency change, not a failure, and it doesn’t affect the test result.

Common Mistakes and Gotchas

A few things worth checking explicitly before you consider a session system done:

  • Not regenerating the session ID at login. OWASP’s guidance is direct: “the session ID must be renewed or regenerated by the web application after any privilege level change.” This tutorial’s session is created fresh at login, which already satisfies that, but if you ever adapt this pattern to reuse a pre-login “anonymous” session ID after authentication, you’ve reintroduced session fixation: an attacker who plants a known session ID in a victim’s browser before login can hijack the now-authenticated session.
  • Comparing raw session tokens with ==. This tutorial avoids the question by hashing the incoming token and using it as a dictionary key, which is a lookup, not a manual comparison. If your design does compare two secrets directly, use hmac.compare_digest, not ==, as shown in the password check in Step 2.
  • Trusting a client-supplied IP header. Covered in Step 6: only read X-Forwarded-For or similar headers if your own trusted proxy is guaranteed to be the one setting them.
  • Forgetting the Secure-cookie-on-localhost gotcha applies to your CI, too. Any automated test suite that spins up your app and hits it over plain HTTP, whether that’s TestClient, requests, or a headless browser configured without HTTPS, needs the same accommodation shown in Step 3.
  • Only testing the happy path. It’s easy to verify that revoking a session works. It’s just as important to verify, as this tutorial did in Step 7, that using a revoked session afterward actually fails instead of silently continuing to work.

How to Confirm It All Works End to End

Before considering a session system like this done, walk through this checklist against your own running server, in order:

  1. Log in and confirm the response marks it as a new device.
  2. Log in again with the identical User-Agent and confirm it’s not flagged as new.
  3. Log in with a different User-Agent and confirm it is flagged as new.
  4. List sessions from one of the logged-in clients and confirm every session shows up, with exactly one marked is_current.
  5. Revoke one specific session from a different client, then confirm the revoked client gets 401 on its next request.
  6. Call the bulk-revoke endpoint and confirm every session except the caller’s own disappears.
  7. Shorten the idle timeout, wait past it, and confirm the session stops working without any explicit revoke call.
  8. Backdate a session’s creation time past the absolute timeout while keeping it “recently active,” and confirm it still gets rejected.

If all eight hold, you have a session system that does what the “Active Sessions” page on any major platform claims to do, not just one that looks right in the one login-then-logout path you happened to click through manually.

Next Steps

This tutorial’s in-memory dictionary is deliberately the simplest possible session store, and it’s the first thing to replace before this goes anywhere near production. A few directions worth exploring next:

  • Swap SESSIONS for Redis (with a TTL matching ABSOLUTE_TIMEOUT_SECONDS, so expired sessions clean themselves up) or a database table, so sessions survive a restart and work across multiple app instances.
  • Replace the print() security alert in Step 3 with a real notification: an email, a Slack webhook, or a push notification, matching what TechCrunch’s guide describes ChatGPT, Claude, and Perplexity already doing.
  • Add OWASP’s recommended reauthentication after high-risk events, like a password change or a new-device login, instead of only alerting on them.
  • Read FastAPI’s own documentation on cookie parameters for the other options Cookie() supports beyond what this tutorial used.
  • Once you’re comfortable with how this works under the hood, go run TechCrunch’s original checklist for real, on whichever AI platforms you actually use, and you’ll know precisely what you’re looking at on the other side of that “Active Sessions” screen.

Tags:

AuthenticationFastAPIPythonsession-managementWeb Security

Share

An Emperor dragonfly in flight against a blurred natural background
Previous Post

CNCF’s Dragonfly Shows Why Kubernetes Image Distribution Doesn’t Need a Database

Close-up photograph of interlocking polished steel chain links
Next Post

ChainDrop Worm Infected Over 400 npm Packages While Leaving Their Source Code Clean

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026