NextAuth + JWT + Express + Prisma — Complete Authentication Flow Explained

When you’re building a full-stack app with Next.js (NextAuth) and a separate Express backend, one of the biggest challenges is:
How does authentication actually flow across frontend → backend → database?
In this blog, I’ll break down a real production-style setup using:
NextAuth (OAuth login)
JWT (session strategy)
Express (API layer)
Prisma (database)
Big Picture Architecture
User → NextAuth → JWT (cookie) → Express → Prisma → DB
This setup handles authentication + authorization cleanly.
1. Providers (Login Methods)
providers: [
GoogleProvider({...}),
GitHubProvider({...}),
]
What it does:
Allows users to log in via:
Google
GitHub
Flow:
User clicks login → OAuth provider → returns profile → NextAuth receives it
2. Secret (VERY IMPORTANT)
secret: process.env.NEXTAUTH_SECRET
Why this matters:
Signs JWT
Verifies JWT
MUST be same in backend (Express)
3. Session Strategy (Critical)
session: {
strategy: "jwt"
}
What this means:
Session is stored as JWT inside cookie
Instead of:
Database sessions
Why you need this
Your backend uses:
jwtVerify(token, secret)
This ONLY works if session = JWT
4. JWT Callback (Core Identity Logic)
jwt: async ({ user, token }) => {
if (user) {
token.sub = user.id;
token.name = user.name;
token.email = user.email;
}
return token;
}
When it runs:
Only during login
What it does:
Stores user info inside JWT
Resulting JWT payload:
{
"sub": "abc123",
"name": "Roodius",
"email": "user@email.com"
}
Key Insight
sub = user.id (primary identity)
This is what your backend uses
5. Session Callback (Frontend Access)
session: async ({ session, token }) => {
if (session.user && token) {
session.user.id = token.sub;
session.user.name = token.name;
session.user.email = token.email;
}
return session;
}
When it runs:
Every time session is fetched
What it does:
Moves data from:
JWT → session object
Final session object:
{
"user": {
"id": "abc123",
"name": "Roodius",
"email": "user@email.com"
}
}
Why this matters
Frontend uses:
const session = await getSession();
Without this, user.id won’t exist
6. signIn Callback (Database Sync)
signIn: async ({ user }) => {
const existing = await prisma.user.findUnique({
where: { email: user.email }
});
if (!existing) {
await prisma.user.create({
data: {
name: user.name,
email: user.email
}
});
}
return true;
}
What it does:
Checks if user exists → creates if not
Flow:
OAuth login → NextAuth → DB check → create user if needed
⚠️ Important
If you use:
PrismaAdapter(prisma)
REMOVE this block (handled automatically)
End-to-End Flow
1. User logs in via Google/GitHub
2. signIn → ensures user exists in DB
3. jwt → stores user.id in token.sub
4. Cookie stores JWT
5. Frontend sends request with cookie
6. Express middleware:
→ verifies JWT
→ extracts payload.sub
→ sets req.user.id
7. Controller uses req.user.id
Backend Middleware (Express)
const { payload } = await jwtVerify(token, secret);
req.user = {
id: payload.sub
};
Result:
req.user.id = authenticated user
No need to send userId manually
Common Mistakes
Sending userId from frontend
{
"userId": "abc123"
}
Security risk
Not using JWT strategy
Backend verification fails
Different NEXTAUTH_SECRET
Token verification breaks
Using DB session + JWT verify together
Mismatch
Best Practices
✔ Use JWT session strategy
✔ Use same secret across apps
✔ Extract identity from token (not request body)
✔ Keep auth logic in middleware
✔ Keep controllers clean

