sessionsValidAfterJWT-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.
auth() call. This is cheap ({ userId: 1 } is already indexed; the projection is a single Date field) but it's not zero.deleteSession solves this with no extra design.signOut() suffices; this pattern is for invalidating sessions on devices you don't control.// userModel
{
// …
sessionsValidAfter: { type: Date, default: null },
}
// passwordReset action
user.password = newPassword;
user.sessionsValidAfter = new Date(); // ← invalidates all existing JWTs
user.failedLoginAttempts = 0;
user.lockoutUntil = null;
await user.save();
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) => { /* … */ });
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.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.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.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.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.