Handling User Logout Correctly
The essential playbook for implementing handling user logout correctly in your SaaS.
Logout is not just a redirect to the login page. A correct logout flow must invalidate the active auth state, clear browser cookies with matching attributes, prevent reuse of old sessions or refresh tokens, and behave consistently across browser, SPA, and API clients.
This page shows a minimal, production-safe logout implementation for session auth and JWT-based setups used in small SaaS products.
sequence diagram for browser/client -> app -> session store or token store during login, authenticated request, logout, and failed post-logout request.
Quick Fix / Quick Setup
Use POST /logout, not GET.
For session auth:
- Invalidate the server-side session.
- Delete the session cookie with matching attributes.
- Clear frontend auth state only after the server confirms success.
For JWT auth:
- Revoke the current refresh token.
- Delete the refresh token cookie.
- Clear frontend auth state.
- Let short-lived access tokens expire naturally unless you need denylist-based immediate revocation.
FastAPI example: session cookie logout
from fastapi import FastAPI, Request
from starlette.responses import JSONResponse
app = FastAPI()
@app.post("/logout")
def logout(request: Request):
# If using server-side sessions, remove session data here
# Example: request.session.clear()
response = JSONResponse({"ok": True})
response.delete_cookie(
key="session",
path="/",
domain=None,
secure=True,
httponly=True,
samesite="lax",
)
return responseFlask example: session logout
from flask import Flask, session, jsonify
app = Flask(__name__)
@app.post("/logout")
def logout_flask():
session.clear()
response = jsonify({"ok": True})
response.delete_cookie(
"session",
path="/",
secure=True,
httponly=True,
samesite="Lax"
)
return responseJWT refresh-token revocation pattern
1. Store refresh tokens in DB with jti/user_id/expires_at/revoked_at
2. On logout, mark current refresh token revoked
3. Delete refresh token cookie
4. Reject revoked tokens on refreshNote: for session auth, clear the server session and delete the cookie using the same cookie name, path, domain, Secure, and SameSite settings used when it was set. For JWT auth, short-lived access tokens usually expire naturally; the critical logout step is revoking and deleting the refresh token.
What’s happening
A logout bug usually means one of these is still true:
- The browser still sends a valid session cookie.
- The backend still accepts a refresh token.
- The frontend removed local user state, but the backend still trusts the client.
- Cookie deletion did not match the original
pathordomain. - A second cookie with the same name still exists on another path or subdomain.
Important behavior:
- Redirecting to
/loginis not logout. - Deleting a cookie fails if cookie attributes do not match.
- In JWT systems, access tokens stay valid until expiry unless you add a denylist.
- In multi-device setups, single-device logout should revoke only the current session/token unless you explicitly support global logout.
Step-by-step implementation
1) Create a dedicated logout endpoint
Use POST /logout.
If auth relies on cookies, keep CSRF protection enabled.
FastAPI
from fastapi import FastAPI, Request, HTTPException
from starlette.responses import JSONResponse
app = FastAPI()
@app.post("/logout")
async def logout(request: Request):
# Validate CSRF here if applicable
response = JSONResponse({"ok": True})
return responseFlask
from flask import Flask, jsonify
app = Flask(__name__)
@app.post("/logout")
def logout():
# Validate CSRF here if applicable
return jsonify({"ok": True})2) Invalidate server trust
Session-based auth
If you use server-side sessions:
session.clear()for framework session state- delete session row from database
- or delete session key from Redis
Example Redis-backed invalidation:
def invalidate_session(redis, session_id: str):
redis.delete(f"session:{session_id}")If you use signed client-side session cookies only, there may be no server record to revoke. In that case, cookie deletion is the primary logout action.
JWT-based auth
Do not treat frontend token deletion as enough if a refresh token remains valid.
Persist refresh tokens in a table like:
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
jti UUID NOT NULL UNIQUE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NULL,
replaced_by_jti UUID NULL
);On logout:
- Read the current refresh token.
- Extract
jtior hash the token. - Mark the record as revoked.
- Delete the cookie.
Example SQL:
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE jti = '00000000-0000-0000-0000-000000000000'
AND revoked_at IS NULL;3) Delete the cookie correctly
Deletion must use the same identifying cookie attributes as the original cookie:
- name
- path
- domain
Security attributes should also stay consistent with your auth setup:
SecureHttpOnlySameSite
Example Set-Cookie for deletion:
Set-Cookie: session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=LaxIf the original cookie was set on .example.com and you delete it without the domain, the original cookie may remain.
Example login and logout settings must match
response.set_cookie(
key="session",
value=session_id,
path="/",
domain=".example.com",
secure=True,
httponly=True,
samesite="lax",
)
response.delete_cookie(
key="session",
path="/",
domain=".example.com",
secure=True,
httponly=True,
samesite="lax",
)4) Handle SPA and frontend state safely
Only clear frontend auth state after the server confirms logout success.
Example:
async function logout() {
const res = await fetch("/logout", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
}
});
if (!res.ok) {
throw new Error("Logout failed");
}
localStorage.removeItem("cached_profile");
sessionStorage.removeItem("ui_session");
// clear auth context/store here
window.location.href = "/login";
}Do not hide backend logout failures by clearing state first.
5) Verify post-logout behavior
After logout:
- protected API calls should return
401or403 - protected pages should redirect only because auth failed
- refresh endpoint should reject revoked refresh tokens
- old session IDs should no longer resolve to authenticated users
6) Support optional global logout separately
Single-device logout and logout-all-devices should be different actions.
Global logout patterns
- revoke all refresh token rows for the user
- delete all active session records for the user
- increment a
token_versionorauth_version
Example:
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE user_id = 123
AND revoked_at IS NULL;User-level auth version:
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1;Then reject tokens whose embedded auth_version is older than the current user record.
Common causes
- Deleting the cookie with a different path or domain than the original cookie.
- Frontend redirects to login but leaves a valid session or refresh token intact.
- Logout endpoint is
GETand gets blocked, cached, or triggered unintentionally. - CORS or fetch configuration omits credentials, so the logout request does not include the auth cookie.
- Multiple cookies with the same name exist across subdomains or paths.
- JWT refresh tokens are not stored server-side and cannot be revoked per device.
- Session store is not shared across instances, causing inconsistent logout behavior.
- Reverse proxy or HTTPS configuration causes
Securecookies to behave differently in production. - Frontend clears local state before server logout succeeds, masking a backend failure.
- CSRF protection blocks logout requests and the frontend ignores the error response.
Debugging tips
Inspect both browser behavior and backend state.
Browser and HTTP checks
Look at:
Cookierequest header on/logoutSet-Cookieresponse header from/logout- whether multiple cookies with the same name exist
- whether
credentials: "include"is present in frontend requests - whether CORS allows credentials with explicit origins
Useful commands
curl -i -X POST https://app.example.com/logout -H 'Cookie: session=abc123'curl -i https://app.example.com/protected -H 'Cookie: session=abc123'curl -i -X POST https://api.example.com/logout \
-H 'Origin: https://app.example.com' \
-H 'Cookie: refresh_token=xyz'Redis session debugging
redis-cli KEYS 'session*'redis-cli GET session:<session_id>Database token debugging
psql "$DATABASE_URL" -c "SELECT id, user_id, revoked_at, expires_at FROM refresh_tokens WHERE user_id = 123 ORDER BY created_at DESC;"psql "$DATABASE_URL" -c "UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = 123;"Log inspection
grep -R "Set-Cookie\|logout\|401\|403" /var/log/nginx /var/log/app 2>/dev/nulljournalctl -u gunicorn -n 200 --no-pageruvicorn app:app --reloadProduction-specific checks
- Verify your reverse proxy forwards scheme headers correctly.
- Confirm all app instances share the same session store.
- Confirm cookie
domainmatches the actual app and API hostnames. - Confirm
SameSite=Nonecookies also useSecure. - Confirm refresh-token revocation is checked on every refresh.
Checklist
- ✓
POST /logoutexists. - ✓ Logout is CSRF-protected when cookie auth is used.
- ✓ Server-side session or refresh token is invalidated on logout.
- ✓ Cookie deletion uses matching name, path, domain,
Secure, andSameSitesettings. - ✓ Frontend clears local auth state only after logout success.
- ✓ Protected endpoints return
401/403immediately after logout. - ✓ Single-device logout is separate from global logout.
- ✓ Refresh token rotation and reuse detection are implemented if using JWT refresh tokens.
- ✓ Logs do not print raw session IDs or tokens.
- ✓ Session storage is shared across instances in production.
For broader release validation, use the SaaS Production Checklist.
Related guides
- Session Management vs JWT (When to Use What)
- Protecting Routes and APIs
- Common Auth Bugs and Fixes
- SaaS Production Checklist
- Structuring a Flask/FastAPI SaaS Project
FAQ
What is the minimum correct logout behavior for session auth?
Invalidate the server-side session and delete the session cookie with matching attributes, then verify protected routes fail immediately.
What is the minimum correct logout behavior for JWT auth?
Revoke the current refresh token, delete the refresh token cookie, clear client auth state, and rely on short access-token expiry unless immediate revocation is required.
Why is my cookie not being deleted?
The delete call must match the original cookie name, path, and domain. If any differ, the browser keeps the original cookie.
Should logout be GET or POST?
Use POST. Logout changes auth state and should not be triggered by prefetching, crawlers, or accidental link visits.
Do I need to revoke access tokens on logout?
Usually not if access tokens are short-lived. Revoking refresh tokens is the main logout control. Use an access-token denylist only if you need immediate invalidation.
Should I store tokens in localStorage?
Avoid storing refresh tokens in localStorage for browser apps. Prefer HttpOnly cookies to reduce XSS exposure.
Why does logout work locally but not in production?
Common causes:
- wrong cookie domain
Securecookies on non-HTTPS environments- incorrect
SameSite - proxy header issues
- CORS credentials mismatch
- multiple cookies across subdomains
- non-shared session storage
How do I support logout from all devices?
Revoke all session or refresh token records for the user, or increment a user auth version and reject tokens issued under older versions.
Final takeaway
Correct logout means invalidating server trust, not just changing frontend state.
For sessions, clear the session and delete the cookie correctly. For JWT setups, revoke the refresh token and clear the cookie or secure storage. Most production logout bugs come from mismatched cookie attributes, stale refresh tokens, or frontend state masking backend auth behavior.
include a sequence diagram showing login creates session or refresh token
include a sequence diagram showing authenticated request succeeds
include a sequence diagram showing logout revokes server state and clears cookie
include a sequence diagram showing next protected request fails
Related implementation and production pages:
- architecture context: SaaS Architecture Overview (From MVP to Production)
- app structure: Structuring a Flask/FastAPI SaaS Project
- release validation: SaaS Production Checklist
- auth strategy: Session Management vs JWT (When to Use What)