My best Auth Flow Advice (Next.js/next-auth case study)
By swapping in PrismaAdapter(prisma)
, I give NextAuth a direct line to my database schema. This tight integration means user and API-key records flow seamlessly between my ORM and authentication layer. Coupled with a JWT strategy locked to a four-hour maximum age, it forms a stateless backbone: every request carries its own session information, and my edge middleware (shown earlier) simply validates the iat
claim rather than hitting the database on each call.
1import { NextAuthOptions } from "next-auth"; 2import { PrismaAdapter } from "@auth/prisma-adapter"; 3import { prisma } from "@/app/utils/prisma"; 4 5export const authOptions: NextAuthOptions = { 6 adapter: PrismaAdapter(prisma) as Adapter, 7 session: { strategy: "jwt", maxAge: 4 * 60 * 60 }, // 4h 8 // …providers, callbacks, events, pages… 9};
I then layer on multiple providers OAuth via Discord, GitHub, and Google, alongside a credentials flow so that every access pattern (social login or classic email/password) funnels through the same session machinery. For OAuth, I explicitly request offline
access and consent
prompts, guaranteeing refresh tokens when available; the Credentials provider uses bcrypt to validate passwords, returning only the minimal user object needed.
1import DiscordProvider from "next-auth/providers/discord"; 2import GithubProvider from "next-auth/providers/github"; 3import GoogleProvider from "next-auth/providers/google"; 4import CredentialsProvider from "next-auth/providers/credentials"; 5 6providers: [ 7 DiscordProvider({ /* consent + offline */ }), 8 GithubProvider({ /* standard GitHub OAuth */ }), 9 GoogleProvider({ /* openid + offline */ }), 10 CredentialsProvider({ 11 name: "Credentials", 12 credentials: { email: {}, password: {} }, 13 async authorize(creds) { 14 const user = await prisma.user.findUnique({ where: { email: creds?.email } }); 15 if (!user?.password) return null; 16 const { compare } = await import("bcrypt"); 17 return (await compare(creds!.password, user.password)) 18 ? { id: user.id, email: user.email, name: user.name } 19 : null; 20 }, 21 }), 22],
On initial sign-in, my jwt
callback stamps every token with the current timestamp (iat
), notes the OAuth provider, and embeds access and refresh tokens along with their expiration. Downstream calls simply inspect these fields, and when the stored access token has expired, I invoke a unified refreshAccessToken()
helper that routes to the correct OAuth endpoint. This keeps token rotation logic encapsulated and transparent.
1callbacks: { 2 async jwt({ token, user, account, trigger, session }) { 3 if (account && user) { 4 return { 5 ...token, 6 iat: Math.floor(Date.now() / 1000), 7 provider: account.provider, 8 accessToken: account.access_token, 9 refreshToken: account.refresh_token, 10 accessTokenExpires: Date.now() + ((account.expires_in as number) || 3600) * 1000, 11 userId: user.id, 12 }; 13 } 14 if (trigger === "update" && session?.user?.id) token.userId = session.user.id; 15 if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) return token; 16 if (["google","discord"].includes(token.provider as string)) { 17 return await refreshAccessToken(token); 18 } 19 return token; 20 }, 21},
Once the JWT is enriched, the session
callback lifts those fields into the NextAuth session object so client code can read session.user.id
, session.accessToken
, and any session.error
. Coupled with an signOut
event that calls reloadSession()
, this guarantees session state is purged immediately on logout, eliminating stale cookies or dangling tokens.
1callbacks: { 2 async session({ session, token }) { 3 session.user.id = token.userId as string; 4 session.accessToken = token.accessToken as string; 5 session.error = token.error as string; 6 return session; 7 }, 8}, 9events: { 10 async signOut() { 11 reloadSession(); 12 }, 13}, 14pages: { 15 signIn: "/auth/signin", 16 error: "/auth/error", 17},
Underneath it all sits refreshAccessToken()
, a provider-agnostic helper that uses getProviderConfig()
to post to the correct OAuth token endpoint. It gracefully handles errors by tagging token.error
, ensuring downstream code can surface a “RefreshAccessTokenError” and trigger re-login flows when necessary. With GitHub explicitly bypassed (no refresh tokens), every supported path converges through a single, well-tested routine.
1async function refreshAccessToken(token: JWT): Promise<JWT> { 2 try { 3 const { url, params } = getProviderConfig(token.provider); 4 const res = await fetch(url, { 5 method: "POST", 6 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 7 body: new URLSearchParams(params(token) as Record<string, string>), 8 }); 9 const data = await res.json(); 10 if (!res.ok) throw data; 11 return { ...token, accessToken: data.access_token, accessTokenExpires: Date.now() + (data.expires_in || 3600) * 1000, refreshToken: data.refresh_token ?? token.refreshToken }; 12 } catch (err) { 13 console.error("Refresh failed:", err); 14 return { ...token, error: "RefreshAccessTokenError" }; 15 } 16} 17 18function getProviderConfig(provider?: string) { 19 const baseParams = (t: JWT) => ({ 20 client_id: process.env[`${provider?.toUpperCase()}_CLIENT_ID`], 21 client_secret: process.env[`${provider?.toUpperCase()}_CLIENT_SECRET`], 22 grant_type: "refresh_token", 23 refresh_token: t.refreshToken, 24 }); 25 switch (provider) { 26 case "google": return { url: "https://oauth2.googleapis.com/token", params: baseParams }; 27 case "discord": return { url: "https://discord.com/api/oauth2/token", params: t => ({ ...baseParams(t), scope: "identify email" }) }; 28 case "github": console.log("GitHub OAuth: no refresh token"); return { url: "https://github.com/login/oauth/access_token", params: t => ({ ...baseParams(t), scope: "user:email" }) }; 29 default: throw new Error("Unsupported provider"); 30 } 31}
This tightly-coupled yet modular authOptions
setup Prisma wiring, JWT lifetimes, OAuth consent, credentials hashing, token rotation, and event hooks forms a seamless, predictable authentication layer that scales with your Next.js app’s demands.