Skip to content

Hard-deleting a user does not invalidate their JWT session #7

@RisingOrange

Description

@RisingOrange

Symptom

After deleting a row from \"user\" (e.g. via SQL or deleteUser() at src/lib/users.ts:87), the affected user remains "logged in" in any browser that holds their JWT cookie. They continue to navigate the dashboard and hit APIs as if nothing changed.

Root cause

NextAuth is configured with session: { strategy: "jwt" } (src/lib/auth.ts:154-156). Sessions live in a stateless, signed cookie — not in the DB. Deleting the user row does not invalidate the cookie because there's no DB session record to delete.

The jwt callback at src/lib/auth.ts:201-228 does refresh the role from DB on every request:

const [dbUser] = await db
  .select({ role: users.role, email: users.email })
  .from(users)
  .where(eq(users.id, token.id as string));

if (dbUser) {
  // ... role refresh
}

But when dbUser is undefined (user was deleted), the if (dbUser) block is silently skipped. token.role stays at whatever it was, and downstream code happily treats the holder as authenticated. There's no path that says "user no longer exists → invalidate the session."

Why this matters

  • Operational confusion. Was caught testing a different bug — admin deleted from DB, browser still showed admin dashboard. Hard to reason about access state.
  • Security adjacent. If an admin offboards a user by deleting their row, the user retains access until their JWT expires (default ~30 days for NextAuth). For most threat models this is fine, but it's a surprising behavior.
  • Stale-token paths. Various downstream API calls might fail in confusing ways or partially work via the global-admin role bypass at /api/workspaces/route.ts:16-19.

Proposed fix

In the jwt callback, when dbUser is undefined for a non-empty token.id, invalidate the session by returning an empty/expired token:

if (token.id) {
  const [dbUser] = await db.select(...)...

  if (!dbUser) {
    // User was deleted — invalidate the session
    return {};
  }

  // ... existing refresh logic
}

NextAuth treats an empty token as unauthenticated, redirects to login on the next protected request. (Confirm exact shape via Auth.js v5 docs — may need return null or specific token shape.)

Acceptance criteria

  • Deleting a user's row → their next request lands them on /login (not the broken dashboard with a ghost token).
  • Manual repro: sign in, delete row in psql, refresh dashboard → redirected to /login.
  • No regression for normal sessions (existing user's role refresh still works).

Context

Discovered while testing PR #2. Documented for follow-up but not in scope of that PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions