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.

browser/client
app
session store or token store

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:

  1. Invalidate the server-side session.
  2. Delete the session cookie with matching attributes.
  3. Clear frontend auth state only after the server confirms success.

For JWT auth:

  1. Revoke the current refresh token.
  2. Delete the refresh token cookie.
  3. Clear frontend auth state.
  4. Let short-lived access tokens expire naturally unless you need denylist-based immediate revocation.

FastAPI example: session cookie logout

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

Flask example: session logout

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

JWT refresh-token revocation pattern

text
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 refresh

Note: 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 path or domain.
  • A second cookie with the same name still exists on another path or subdomain.

Important behavior:

  • Redirecting to /login is 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

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

Flask

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

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

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

  1. Read the current refresh token.
  2. Extract jti or hash the token.
  3. Mark the record as revoked.
  4. Delete the cookie.

Example SQL:

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:

  • Secure
  • HttpOnly
  • SameSite

Example Set-Cookie for deletion:

http
Set-Cookie: session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax

If 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

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

ts
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 401 or 403
  • 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_version or auth_version

Example:

sql
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE user_id = 123
  AND revoked_at IS NULL;

User-level auth version:

sql
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 GET and 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 Secure cookies 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:

  • Cookie request header on /logout
  • Set-Cookie response 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

bash
curl -i -X POST https://app.example.com/logout -H 'Cookie: session=abc123'
bash
curl -i https://app.example.com/protected -H 'Cookie: session=abc123'
bash
curl -i -X POST https://api.example.com/logout \
  -H 'Origin: https://app.example.com' \
  -H 'Cookie: refresh_token=xyz'

Redis session debugging

bash
redis-cli KEYS 'session*'
bash
redis-cli GET session:<session_id>

Database token debugging

bash
psql "$DATABASE_URL" -c "SELECT id, user_id, revoked_at, expires_at FROM refresh_tokens WHERE user_id = 123 ORDER BY created_at DESC;"
bash
psql "$DATABASE_URL" -c "UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = 123;"

Log inspection

bash
grep -R "Set-Cookie\|logout\|401\|403" /var/log/nginx /var/log/app 2>/dev/null
bash
journalctl -u gunicorn -n 200 --no-pager
bash
uvicorn app:app --reload

Production-specific checks

  • Verify your reverse proxy forwards scheme headers correctly.
  • Confirm all app instances share the same session store.
  • Confirm cookie domain matches the actual app and API hostnames.
  • Confirm SameSite=None cookies also use Secure.
  • Confirm refresh-token revocation is checked on every refresh.

Checklist

  • POST /logout exists.
  • 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, and SameSite settings.
  • Frontend clears local auth state only after logout success.
  • Protected endpoints return 401/403 immediately 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


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
  • Secure cookies 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.

Browser
Nginx
App
DB
GET /
Proxy Request
Query Data
Data Result
HTML Response
Render Page

include a sequence diagram showing login creates session or refresh token

Browser
App
DB
Email
POST /register
Create User (unverified)
Send Verification
Click Email Link
Mark Verified
Redirect to Dashboard

include a sequence diagram showing authenticated request succeeds

Browser
App
DB
Email
POST /register
Create User (unverified)
Send Verification
Click Email Link
Mark Verified
Redirect to Dashboard

include a sequence diagram showing logout revokes server state and clears cookie

Browser
Nginx
App
DB
GET /
Proxy Request
Query Data
Data Result
HTML Response
Render Page

include a sequence diagram showing next protected request fails

Related implementation and production pages: