DEV Community

Anas Sheikh
Anas Sheikh

Posted on

You Changed a User's Role in the Database, But Their JWT Doesn't Know That Yet

You find out a user's permissions need to change right now, maybe they're being demoted from admin, maybe their account access is being revoked entirely after an incident. You update the database. The change looks instant in your admin panel. Meanwhile, that user keeps acting with their old permissions for as long as their existing session token stays valid, sometimes for hours, sometimes for days, and nothing about your database update touched that.

Why This Happens: JWTs Don't Check Back In

A JWT-based session, the kind most Next.js apps use with something like NextAuth, is a self-contained token. Once it's issued, your server verifies it by checking its signature and expiration, not by looking anything up in your database. That's the entire point of a stateless token, it avoids a database round trip on every single request.

// middleware.ts
export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  // This only checks: is the signature valid, has it expired?
  // It does NOT check: does this role still match what's in the database right now?
  if (token?.role !== 'admin') {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

The role embedded in that token was correct the moment it was issued. It is not re-verified against the database on every request, because doing so would defeat the entire performance benefit of using a stateless token in the first place. If you update the user's role in the database five minutes after their token was issued, the token itself still says the old role, and will keep saying that until it expires and they're forced to log in again.

Where This Actually Causes Real Problems

For a routine role change, like a title update that doesn't affect sensitive access, a short delay before it takes effect is a minor inconvenience, at worst, mildly confusing. For a security-sensitive revocation, suspending an account after detecting suspicious activity, revoking admin access after an employee departure, removing a compromised API key's associated session, that same delay is a real exposure window. The access you believed you'd just shut off is still fully functional from the attacker's or departed employee's side, for however long their existing token remains valid.

Why This Is Easy to Miss Building the App

Session and role logic almost always gets tested by logging in fresh after making a change, which issues a brand new token with the updated role baked in, making the fix look like it worked perfectly. The actual bug only shows up for a user who's already logged in with an existing token at the moment the change happens, a scenario that's genuinely easy to never think to test, since it requires two separate sessions and specific timing to notice.

The Fix: Force Re-Verification for Anything Sensitive

The cleanest general fix is shortening token lifetime for sensitive roles and pairing it with refresh logic that re-checks the database:

// auth.ts (NextAuth config)
export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
    maxAge: 15 * 60, // 15 minutes instead of days, forces more frequent re-issuance
  },
  callbacks: {
    async jwt({ token, user, trigger }) {
      if (trigger === 'update' || !token.lastChecked || Date.now() - (token.lastChecked as number) > 60_000) {
        // Re-fetch the current role from the database periodically,
        // not just at initial login
        const dbUser = await User.findById(token.sub).select('role status');
        if (!dbUser || dbUser.status === 'suspended') {
          return { ...token, role: null, revoked: true };
        }
        token.role = dbUser.role;
        token.lastChecked = Date.now();
      }
      return token;
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

For genuinely urgent revocations, like an active security incident, token expiration alone isn't fast enough, and this is where some form of server-side revocation check becomes necessary, a denylist of revoked token IDs checked against a fast store like Redis, even if you're otherwise fully stateless everywhere else. It adds back a small lookup cost, but only for the specific case where "wait until the token expires naturally" genuinely isn't an acceptable answer.

// A minimal revocation check added to middleware
const isRevoked = await redis.sismember('revoked_sessions', token.jti);
if (isRevoked) {
  return NextResponse.redirect(new URL('/login', request.url));
}
Enter fullscreen mode Exit fullscreen mode

The Rule

A JWT's claims are a snapshot from the moment it was issued, not a live view of your database. Any role or permission check based purely on token contents can be stale for as long as that token remains valid. For routine data, that staleness is usually an acceptable tradeoff for performance. For anything where immediate revocation actually matters, you need either short-lived tokens with periodic re-verification, or an explicit revocation check, because the token itself has no way of knowing anything changed.

I treat this distinction explicitly in the auth setup behind my Next.js dashboard templates, where role-based access control needs to actually mean something the moment it changes, not sometime later.

Get the templates: https://pixelanas.gumroad.com

Check your own app, specifically how fast a role change or account suspension actually takes effect for a user with an existing session. If the honest answer is "whenever their token expires," that's worth deciding on purpose rather than by default. Drop what you find in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)