Skip to content
Vibe Coders

Session Handling AI Tools Get Consistently Wrong

Session Handling AI Tools Get Consistently Wrong When an AI coding tool builds you an authenticated app, the login screen almost always works. A user types an email, receives a magic link or enters a...

By WitsCode9 min read

When an AI coding tool builds you an authenticated app, the login screen almost always works. A user types an email, receives a magic link or enters a password, and ends up inside the dashboard. The prompt was satisfied. The demo recorded. The deploy went green. What the prompt almost never asked for, and what the model therefore almost never produces, is everything that has to happen after that first successful login. Sessions are where vibe-coded apps quietly rot. The bugs are invisible in happy-path testing, they never trip the AI tool's preview, and they surface weeks later as a support ticket that reads "I changed my password and my ex still has access to my account."

This article walks through the four session pitfalls that show up in almost every AI-generated Supabase app we audit, explains why the model keeps making them, and shows the concrete fix for each. It assumes you are using Supabase Auth, because that is what Lovable, Bolt, v0, and most Cursor-driven agents reach for by default, but the same patterns apply to any JWT-plus-refresh-token system.

How Supabase Sessions Actually Work

Before the pitfalls make sense, the lifecycle has to be clear. When a user signs in, Supabase Auth issues two things. The first is an access token, a signed JWT that lasts one hour by default and carries the user's id, role, and a session identifier. The second is a refresh token, an opaque random string stored in the auth.refresh_tokens table and tied to a row in auth.sessions. The browser keeps both. When the access token is close to expiring, the Supabase client silently calls the token endpoint with the refresh token, receives a new access token, and receives a rotated refresh token in the same response. The old refresh token is marked revoked in the database, with a parent link to the new one.

That rotation matters because it is the only thing standing between a stolen token and a thirty-day attacker session. If the same revoked refresh token is presented twice, Supabase detects the reuse and kills the entire token family, logging the user out everywhere. This is enabled by default, and the first SERP miss is that almost no tutorial tells you to verify it is still enabled, and almost no AI-generated project explains what happens if someone turns it off in the dashboard "to fix a bug."

With that in place, here are the four failures.

Pitfall One: Logout That Does Not Log Out

The symptom is simple. A user clicks "Sign out" on their laptop. They feel safe. Meanwhile their phone, their work desktop, and the tablet they lent to a friend three months ago all still hold valid sessions that will keep refreshing themselves for another thirty days. The user thinks the account is closed. The account is not closed.

This happens because AI tools generate logout handlers that look like this:

// what the AI writes
async function handleLogout() {
  await supabase.auth.signOut({ scope: 'local' })
  router.push('/login')
}

The scope: 'local' call only clears the current browser's tokens. It does not touch the auth.sessions table. Every other device still has a live refresh token and will mint new access tokens on its normal schedule. Sometimes the AI gets even lazier and writes localStorage.clear() followed by a redirect, in which case nothing at all is revoked server-side.

The fix is to let Supabase's default do its job:

async function handleLogout() {
  const { error } = await supabase.auth.signOut() // defaults to global
  if (error) console.error(error)
  router.push('/login')
}

With no scope argument, signOut defaults to global, which hits POST /auth/v1/logout with the user's JWT and revokes every refresh token tied to that user. Every other device discovers this the next time its client tries to refresh and gets a 401 back. If you genuinely need a "sign out of this tab only" button, keep scope: 'local', but make it an explicit secondary action, not the default.

For the paranoid case, where a user reports a stolen laptop and wants to nuke everything from another device, you need an admin-side kill switch. Add a Postgres function callable from your settings page:

create or replace function revoke_all_my_sessions()
returns void
language plpgsql
security definer
set search_path = auth, public
as $$
begin
  delete from auth.sessions where user_id = auth.uid();
end;
$$;

grant execute on function revoke_all_my_sessions() to authenticated;

Deleting from auth.sessions cascades to auth.refresh_tokens. The caller's own session is included, which is usually what you want. This is the hardest possible logout, and it is the one AI-generated settings pages almost never ship with.

Pitfall Two: Refresh Token Rotation That Nobody Verifies

Supabase ships with refresh token rotation enabled. When a client refreshes, the response contains a brand new refresh token, and the previous one is marked revoked with a ten-second grace window to absorb network race conditions between parallel tabs. If an attacker exfiltrates a refresh token through an XSS payload, as soon as the legitimate user's browser makes its next refresh call, the attacker's copy becomes the "reuse" event that kills the family. The attacker is evicted automatically.

The AI failure here is not that the model turns rotation off. It does not. The failure is that the model does not understand what it is protecting, and so it happily introduces code that breaks the chain. We see refresh tokens being copied into a second storage location "for safety," manually passed to setSession on page load, or duplicated into a cookie without the original localStorage copy being cleared. Every parallel write is a race condition waiting to trigger a false-positive reuse detection, which looks to the user like a random forced logout, which a human developer then "fixes" by widening the reuse interval in the dashboard to ten minutes. At which point rotation is effectively off, because an attacker has ten minutes to refresh the stolen token before the reuse alarm fires.

The correct pattern is to use exactly one storage path. On the client, let supabase-js manage localStorage untouched. On the server, use @supabase/ssr and let it manage the sb-<project>-auth-token cookie chunks. Do not mix. Never call setSession with tokens you pulled out of storage yourself. If the client library cannot find its tokens, the user needs to log in again, and that is fine. The observable check that rotation is working is the parent column in auth.refresh_tokens: for any active session, you should see a chain of revoked rows linked by parent back to the original login. If that chain is empty, rotation is not happening, and you should find the code path that is bypassing the client library.

Pitfall Three: No Concurrent Session Limit

Supabase has no built-in concurrent session cap. A user can log in from fifty devices and every one of them will hold a valid session until it expires or is explicitly revoked. For most consumer apps this is fine. For anything with per-seat billing, shared account abuse concerns, or a compliance story, it is a liability, and the AI tool will never raise the issue because nobody prompted it.

The fix is a database trigger on auth.sessions. Supabase exposes this table, and you can attach logic to new-session inserts:

create or replace function enforce_session_limit()
returns trigger
language plpgsql
security definer
as $$
declare
  max_sessions constant int := 3;
  excess_count int;
begin
  select count(*) - max_sessions
    into excess_count
  from auth.sessions
  where user_id = new.user_id;

  if excess_count > 0 then
    delete from auth.sessions
    where id in (
      select id from auth.sessions
      where user_id = new.user_id
        and id <> new.id
      order by created_at asc
      limit excess_count
    );
  end if;

  return new;
end;
$$;

create trigger enforce_session_limit_trigger
after insert on auth.sessions
for each row execute function enforce_session_limit();

When a fourth device logs in, the oldest of the three existing sessions is deleted, which cascades to its refresh token, which means the next time that stale device tries to refresh it gets a 401 and the user sees a login screen. The cap is whatever integer you want. Three is a reasonable default for prosumer apps. One is correct for anything where simultaneous logins are explicitly disallowed.

The variation worth mentioning is a "sessions" screen in user settings. Query auth.sessions via an RLS-protected view, show the user their devices with user-agent and last-seen metadata from auth.refresh_tokens, and give them a revoke button per row. AI tools do not build this by default because nobody prompts for it, and it is the single most effective trust-building UI a security-sensitive product can add.

Pitfall Four: Password Change That Leaves Old Sessions Alive

This is the SERP miss that bothers me most. When a user changes their password, most tutorials and most AI-generated flows call exactly this:

await supabase.auth.updateUser({ password: newPassword })

The password row updates. The current session keeps working, because the current session's refresh token is not invalidated by a password change. And critically, every other session belonging to that user also keeps working. If the reason the user is changing their password is that they suspect compromise, the attacker's session survives the password change untouched. The whole point of the password change, from the user's mental model, is defeated.

The fix is two lines:

const { error: updateErr } = await supabase.auth.updateUser({
  password: newPassword,
})
if (updateErr) throw updateErr

const { error: signOutErr } = await supabase.auth.signOut({
  scope: 'others',
})
if (signOutErr) throw signOutErr

scope: 'others' revokes every session for the user except the one making the call. The user stays logged in on the device they just used to change their password, and every other device is forced back to the login screen with its new credential requirement. The same treatment should apply to email changes, MFA factor enrollment, and any recovery-flow password reset. If you use a recovery link to set a new password, that recovery session should call signOut({ scope: 'others' }) before redirecting to the dashboard, so that a second recovery-link user cannot ride in on a previous recovery session.

There is a related trap in the password recovery flow itself. Supabase issues a temporary session when a user clicks a recovery link. That session is a real session, and if the user abandons the flow without completing it, the session sits in auth.sessions waiting for its refresh token to expire naturally. A reasonable safety rail is to have your recovery page call signOut({ scope: 'local' }) in a cleanup handler if the user navigates away without submitting the new password.

What "Good" Looks Like End to End

A Supabase app with hardened session handling has five properties. The logout button calls signOut() with no scope and therefore revokes everything. The password change flow is followed by signOut({ scope: 'others' }). The settings page includes a "Sign out of all devices" action that runs a security-definer function deleting the user's rows from auth.sessions. A database trigger caps concurrent sessions at whatever number your product tolerates. And refresh token rotation is verified enabled, with a single storage path on both client and server, so that the reuse-detection alarm is actually armed.

None of these are hard. Each is a handful of lines. The reason vibe-coded apps ship without them is that the prompts that built the apps never mentioned sessions, the model had no reason to volunteer the work, and the preview environment never exercises multi-device scenarios. You find out you are missing them the first time a real user is unhappy.

Why AI Tools Keep Missing This

The pattern across all four pitfalls is the same. Session security is the kind of work that only matters in states the happy path never visits. Multi-device. Password compromise. Long-lived sessions. Recovery flows that get interrupted. Prompts describe what a user is supposed to do. They do not describe what an attacker will try to do, and they rarely describe what a legitimate user will do six weeks after signup when their phone is stolen. Models generate code for the described behavior. The undescribed behavior has to be added by someone who knows it needs adding.

This is the last-mile gap. An AI tool gets you a working login. A human who knows Supabase's auth.sessions table exists, who has read the refresh token rotation docs, and who has seen an incident where a forgotten tablet held a live session for a month, is who closes the gap.

→ WitsCode is where vibe-coded apps come to have their session handling made boring and correct. We audit the four pitfalls above, wire in the missing SQL and client code, and ship you a sessions UI your users will actually trust. The AI writes the app. We make sure it stays yours.

Get weekly field notes.

Practical writing on shipping products, straight to your inbox. No spam.

Need help with this?

MVP Development

We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.

Talk to us

Want to discuss vibe coders for your business?

Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.