Auth.js session revocation via sessionsValidAfter

activetype/resourcetech/authtech/security

Auth.js session revocation via sessionsValidAfter

TL;DR

JWT-strategy Auth.js sessions are stateless — there's no DB-side session row to mutate, so revocation is non-trivial. This pattern adds a single sessionsValidAfter: Date | null field on the user. Compare it against the JWT's iat claim in the Node-side session callback (one indexed findOne per request); JWTs minted before the cutoff are treated as logged out. Bump the field on password reset (mandatory) and on any "log out all devices" trigger (optional). Same field powers both flows.

When to use

  • You're on JWT session strategy (forced by the Credentials provider in v5, or chosen deliberately for stateless scaling).
  • You need to invalidate existing sessions on password reset, account compromise, or "log out all devices" — and you don't want to pay the cost of switching to the database session strategy.
  • You're willing to add one indexed DB read per server-side auth() call. This is cheap ({ userId: 1 } is already indexed; the projection is a single Date field) but it's not zero.

When NOT to use

  • You're already on database sessions — deleteSession solves this with no extra design.
  • You're not on Auth.js at all and can stamp a "valid-after" claim directly into your JWT signing. Same idea, different mechanics.
  • You only need single-device logout — signOut() suffices; this pattern is for invalidating sessions on devices you don't control.

Sketch

Schema

// userModel
{
  // …
  sessionsValidAfter: { type: Date, default: null },
}

Bump it on password reset

// passwordReset action
user.password = newPassword;
user.sessionsValidAfter = new Date();  // ← invalidates all existing JWTs
user.failedLoginAttempts = 0;
user.lockoutUntil = null;
await user.save();

Check it in the session callback (Node-side only)

The trick: the edge runtime can't do DB lookups, but middleware runs on the edge. Use two NextAuth(...) instances with two session callbacks:

// src/auth.config.ts — edge-safe, no DB
export const authConfig = {
  session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 },
  callbacks: {
    session({ session, token }) {
      // Pure projection from token to session.user. No DB.
      session.user.userId = token.userId;
      // …
      return session;
    },
  },
} satisfies NextAuthConfig;
// src/auth.ts — Node, with DB-aware override
export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  adapter: /* … */,
  callbacks: {
    ...authConfig.callbacks,
    async session(params) {
      const base = await authConfig.callbacks!.session!(params as any);
      const token = (params as any).token as { userId?: string; iat?: number };
      if (!token.userId) return base;
      try {
        const user = await User.findOne(
          { userId: token.userId },
          { sessionsValidAfter: 1 }
        ).lean();
        const cutoff = user?.sessionsValidAfter as Date | null;
        if (cutoff && typeof token.iat === "number") {
          if (token.iat * 1000 < cutoff.getTime()) {
            // JWT issued before the cutoff → effectively logged out.
            return { ...base, user: undefined as any };
          }
        }
      } catch {
        // Fail-open: DB blip shouldn't sign everyone out.
        return base;
      }
      return base;
    },
  },
});
// src/middleware.ts — edge-only, uses authConfig directly
const { auth } = NextAuth(authConfig);
export default auth((req) => { /* … */ });

Trade-offs

  • One indexed read per server-side auth() call. On a hot route that calls auth() multiple times (Server Component + Server Action), each invocation pays. Cache auth() per request if it shows up in flamegraphs.
  • Edge middleware doesn't enforce revocation. A revoked session can still hit a route's HTML — but every auth() call inside that route returns "no user", so server actions fail and data fetches return null. UX cost: a brief glimpse of the chrome before redirect. Security cost: zero.
  • Fail-open on DB outages. If the DB lookup throws, the session passes through unverified. Acceptable for most products (a Mongo blip not signing everyone out); not acceptable for the highest-security applications (where you'd want fail-closed).
  • No per-session granularity. The cutoff bumps invalidate all of the user's existing JWTs. If you need "revoke this one device", you need a JTI list (more code, more state).

Origins

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — production use as of Phase B 2026-05-19

Pitfalls

  • Document the fail-open behavior — it's a real security property that's invisible from the outside. If your threat model demands fail-closed, change the catch to return { ...base, user: undefined as any }.
  • token.iat is in seconds, Date.getTime() is in milliseconds. Off-by-1000 here will either sign everyone out or no one out. Multiply.
  • The edge session callback fires too. Be sure your edge callback doesn't reach for DB primitives accidentally; it's a Node-only mistake that surfaces as a Vercel deploy failure on the middleware bundle.
  • When the user signs back in after a reset, the new JWT's iat is naturally >= sessionsValidAfter, so they're fine. Don't try to clear sessionsValidAfter after a successful sign-in — leave it; future resets will bump it again.
  • signOut() is unaffected. It clears the cookie on the device that called it. Use sessionsValidAfter for the other devices the user doesn't have in hand.

See also

  • [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]] — the surrounding setup
  • [[Resources/Tech/Auth.js/Auth.js custom mongoose adapter pattern]] — the user-storage layer this hooks into