Skip to main content

Command Palette

Search for a command to run...

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

Updated
4 min read
NextAuth + JWT + Express + Prisma — Complete Authentication Flow Explained
R
React, Next.js, Cloudflare Workers (Wrangler), C++, Tailwind CSS, Node.js, TypeScript, JavaScript, Zod, SQL, MongoDB, PostgreSQL, Prisma, npm

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


Github

Twitter