Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Session still valid after logout: how to fix it

FIX / SESSION AFTER LOGOUT / 9 MIN READ

A session is still valid after logout when the server does not delete or reject it, so revoke the session on the server and shorten the token life. Clearing the token in the browser is not enough. A copy of the token works until the server stops accepting it. This guide shows how to confirm the problem, fix it, and check for misuse.

What does “session still valid after logout” mean?

It means that a request with an old session token still succeeds after the user signs out. The user clicks “Log out” and sees the login page. The server, however, still treats the old token as valid.

Two designs cause this problem. Each design needs a different fix.

Design What the browser holds Why the session survives logout
Server session A cookie with a session ID The server does not delete the session record
JWT access token A signed token with an expiry time The server checks only the signature and the expiry time

Many vibe coding tools write a logout function that does only one thing. The function removes the token from localStorage or clears the cookie. This changes the browser, but it does not change the server.

The risk is a stolen token. An attacker can copy a token from a shared computer, a browser extension, a log file, or a proxy. If the user then signs out, the user expects the attacker to lose access. With a weak logout, the attacker keeps access.

The OWASP Session Management Cheat Sheet states that the web application must invalidate the session at least on the server side. It also states that deleting a cookie alone does not invalidate a stolen copy of the session ID.

How do I confirm that my logout is weak?

Use a test that copies the token before logout and uses it after logout. You need a test account. Do not use a real customer account.

  1. Sign in to your app with the test account.
  2. Open DevTools and select the Network tab.
  3. Find a request to your API that needs sign-in. Copy the request as cURL. In Chrome, use Copy as cURL.
  4. Run the cURL command in a terminal. Record the status code. It must be 200.
  5. Click the logout button in the app.
  6. Run the same cURL command again. Record the status code.

If the second request returns 200 and the same data, the server still accepts the old token. The expected result is 401 or 403.

A minimal test for a token in a header looks like this:

TOKEN="paste-the-test-account-token-here"

curl -s -o /dev/null -w "%{http_code}\n" \
  https://your-app.example/api/me \
  -H "Authorization: Bearer $TOKEN"

Run the command before logout and after logout. Run it again after the token expiry time. The result after expiry must be 401.

Warning: Do not paste a real token into a public tool, a ticket, or a chat. A valid token gives access to the account.

Also test a second case. Sign in on two browsers with the same account. Sign out in the first browser. Check if the second browser still works. You decide if that result is correct for your product. Many apps sign out only the current device, and some sign out all devices.

How long does the old token live?

For a JWT, the token lives until the exp claim. Decode the token payload and read exp. It is a Unix time in seconds.

echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null

The output is JSON. Compare exp with iat. The difference is the token life. If the life is 24 hours or 30 days, a stolen token has a long window of use.

What do I do first? Fix it now, in order of urgency

Do these steps in this order. Steps 1 to 3 reduce the risk today. Steps 4 to 6 remove the cause.

Caution: If you revoke all sessions, every user must sign in again. Do this at a quiet time, and tell your users first.

  1. Decide if a token was stolen. If you have a reason to think that a token leaked, revoke all sessions now. See the section on checking the damage.
  2. Shorten the token life. Set the access token life to a short value, such as 15 minutes to 1 hour. A short life limits the use of a stolen copy.
  3. Revoke the session on the server at logout. The next sections show how to do this.
  4. Check the session record on sensitive routes. Routes for payments, admin, and account changes need a live session check.
  5. Revoke all sessions when the password or the email changes. A user who changes the password expects old sessions to end.
  6. Run the cURL test again. Use the test from the confirm section on the new deployment.

How do I revoke the session in Supabase?

Supabase has a server-side sign out. The signOut reference lists three scopes:

Scope What it does
global Signs out the user on every device. This is the default.
local Signs out only the current session.
others Signs out all other sessions and keeps the current one.

Call signOut from the client. Choose the scope on purpose:

// End every session of this user
await supabase.auth.signOut({ scope: "global" });

// End only this browser
await supabase.auth.signOut({ scope: "local" });

The same reference gives an important limit. Signing out does not invalidate an access token that Supabase already issued. The reference states that there is no way to revoke the access token JWT until it expires. The server-side sign out revokes the refresh tokens, so the session cannot create new access tokens.

The result is a short window. After logout, the old access token works until it expires. The Supabase sessions guide states that the default access token life is one hour. The guide advises against values above one hour. It also advises against values below five minutes.

For most apps, this window is acceptable. For sensitive actions, close the window with a session check. The sessions guide explains how: every access token has a session_id claim. This claim matches the primary key of the auth.sessions table. If the row does not exist, the user signed out.

You can use this check in a database function that guards a sensitive action. Use it only for the most sensitive routes. The guide says that most apps rarely need this stronger guarantee.

create or replace function public.session_is_live()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1
    from auth.sessions
    where id = (select (auth.jwt() ->> 'session_id')::uuid)
  );
$$;

Use the function inside a Row Level Security (RLS) policy for a sensitive table:

create policy "live session only" on public.billing_settings
  as restrictive
  for all
  to authenticated
  using ((select public.session_is_live()));

A restrictive policy adds a condition to the other policies. It does not replace them. For the base policies, read the Supabase RLS guide.

The sessions guide also lists three settings that limit the life of a session. These settings are the session time-box, the inactivity timeout, and the single session per user option. The guide states that these settings need a Pro Plan or higher. The settings apply the next time a session refreshes.

If your app uses a session ID in a cookie, delete the session record on the server. Then clear the cookie.

The order is important. If you clear only the cookie, a copy of the ID still works. If you delete the record, every copy of the ID stops working.

// Express-style example
app.post("/logout", async (req, res) => {
  const sessionId = req.cookies.sid;
  if (sessionId) await db.sessions.delete({ id: sessionId });

  res.clearCookie("sid", { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
  res.set("Clear-Site-Data", '"cache", "cookies", "storage"');
  return res.status(204).end();
});

The db.sessions.delete call is a placeholder for your session store. Use the call of your own library. Most session libraries have a destroy method.

The OWASP cheat sheet recommends the Clear-Site-Data header at logout. The header removes cached data from the browser. The header is a defense in depth. The server-side delete is the real control.

Also make sure that the logout route needs a valid session and a safe request method. Use POST, not GET. A GET logout route can be triggered by an image tag on another site.

How do I fix the problem with self-made JWTs?

If your app creates its own JWTs, you have three options. Choose the simplest option that meets your risk.

Option How it works Cost
Short token life Tokens expire in 5 to 15 minutes. A refresh token creates new ones. Low. The refresh token must be revocable.
Session ID claim Each token holds a sid claim. The server checks the session table on sensitive routes. Medium. One lookup per sensitive request.
Deny list At logout, the server stores the token ID until the token expires. Medium. Every request checks the list.

The first option fits most small apps. Store the refresh token in your database. At logout, delete the refresh token record. The user cannot create a new access token. The old access token expires in minutes.

The second option gives immediate revocation on the routes that need it. The check is one query:

select 1 from sessions where id = $1 and revoked_at is null and expires_at > now();

Note: A JWT library that only verifies the signature and the exp claim does not do this check. You must write the check in your own code.

Do not store a long-lived JWT in localStorage and rely on the browser to delete it. Any script on the page can read localStorage. For the risks of the storage choice, read authentication implementation.

How do I check the damage?

Check if anyone used a session after the user signed out. The method depends on the logs that you have.

  1. Collect the sign out times. For Supabase, read the Auth logs in the dashboard. For your own server, read your logout route logs.
  2. Collect the requests after logout. Look for requests with the same session ID or the same user ID after the sign out time.
  3. Compare the network source. Look at the IP address and the user agent. A request from a new country or a new device after logout is a warning sign.
  4. Check sensitive actions. Look for password changes, email changes, payment changes, data exports, and new API keys.
  5. Check the active sessions. In Supabase, a user with an active session has a row in auth.sessions. Read the table in the SQL editor with an admin role.
select id, user_id, created_at, updated_at, user_agent, ip
from auth.sessions
where user_id = 'USER-UUID-HERE'
order by updated_at desc;

If you see a session that the user does not know, remove it. Then ask the user to change the password. Removing a session row stops the refresh. The access token works until it expires.

If you cannot answer these questions because you have no logs, add request logging now. Log the user ID, the session ID, the route, and the time. Do not log the token itself.

How do I stop the problem next time?

Use these controls as a standard for each app.

  1. Write a logout test. The test signs in, copies the token, signs out, and calls a protected route with the old token. The test must expect 401 for apps with immediate revocation. For apps with a token life, the test must expect 401 after the expiry time.
  2. Keep tokens short. Set the shortest token life that your users can accept.
  3. Revoke on credential change. When the password, the email, or the MFA settings change, revoke all other sessions.
  4. Offer a sign out of all devices button. In Supabase, use the global scope.
  5. Put sensitive actions behind a fresh check. For payments and account deletion, ask for the password again or check the session record.
  6. Use secure cookie flags. Set HttpOnly, Secure, and SameSite on session cookies.
  7. Review the AI-generated logout code. Search your code for localStorage.removeItem in the logout function. If that is the only line, the logout is weak.

Reset flows have related risks. For example, a reset link that works more than once. See auth flows: magic links, OTP, and reset for those cases.

When should I get help?

Get help in these cases:

  • You find requests with a valid session after a user signed out, and the actions look harmful. Revoke all sessions first. Then ask a security engineer to review the logs.
  • Your app handles payments, health data, or other regulated data. A fixed list of session rules may apply to you.
  • You use a third-party auth service and you cannot find a revoke option. Read the documentation of the service, or contact its support team.
  • You do not know which routes check the session record. A scan of the deployed app can test the routes with an old token.

After you fix the problem, run the cURL test again. Sign in, copy the request, sign out, and send the request one more time. The result must match the design that you chose.

Common questions

Why does my JWT still work after I sign out?
A JWT is a signed token that the server can verify without a database lookup. The token stays valid until its expiry time, unless the server also checks a session record.
Is a one-hour token life after logout a security bug?
It depends on the app. For a blog it is a low risk. For billing, admin, or health data, it is too long. Shorten the life or check the session record on those routes.
Does deleting the cookie end the session?
No. The cookie is only a copy of the session ID. A stolen copy still works until the server removes the session.
Should I switch from JWTs to server sessions?
Not always. A short token life and a session check on sensitive routes solve most cases. Server sessions give instant revocation, but they need a store.
Does a password change end old sessions?
Only if you code it that way. Revoke all sessions of the user when the password changes, and show the user a sign out of all devices option.

Test your logout

A VibeEval scan signs in to your deployed app, signs out, and reports the requests that the server still accepts with the old session.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →