Modern SaaS Authentication: NextAuth with Third‑Party Login Integration
This course dives deep into the most challenging aspect of building a SaaS product: a robust, scalable, and secure authentication system. We’ll walk through every step of integrating NextAuth.js v5 into a Next.js application, connecting it to Supabase for database persistence, and adding Google, GitHub, and Line as OAuth providers. By the end, you’ll own a fully functional member‑management dashboard that can be deployed to production with confidence.
Course Overview
| Chapter | Topic | Implementation | |---------|-------|----------------| | 1 | Environment Setup | Initialize Next.js + NextAuth | | 2 | Credentials Provider | Email / Password login | | 3 | OAuth Providers | Google, GitHub, Line | | 4 | Database Session | Prisma + PostgreSQL | | 5 | Callbacks & JWT | Custom JWT claims, session shaping |
Each chapter contains:
- What – Core concepts and terminology.
- Why – Business value, security implications, and ROI.
- How – Step‑by‑step instructions, code snippets, and best practices.
Chapter 1 – Environment Setup
What
We’ll create a fresh Next.js project, install NextAuth.js v5, and configure the basic authentication scaffold. This foundation is critical because every subsequent feature depends on a correctly wired auth layer.
Why
- Developer Velocity – A clean starter saves hours of boilerplate.
- Security Baseline – NextAuth’s default configuration follows OWASP best practices.
- Scalability – The architecture is ready for horizontal scaling and serverless deployment.
How
-
Create the project
npx create-next-app@latest nextauth-saas-demo --typescript cd nextauth-saas-demo -
Install dependencies
npm install next-auth @next-auth/prisma-adapter prisma @prisma/client -
Initialize Prisma
npx prisma initEdit
prisma/schema.prismato include the default NextAuth models:datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model Account { id Int @id @default(autoincrement()) userId Int type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Session { id String @id @default(uuid()) sessionToken String @unique userId Int expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model User { id Int @id @default(autoincrement()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] } model VerificationToken { identifier String token String @unique expires DateTime } -
Run migrations
npx prisma migrate dev --name init -
Create the NextAuth API route
Createpages/api/auth/[...nextauth].ts:import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; import LineProvider from "next-auth/providers/line"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { prisma } from "../../../lib/prisma"; export default NextAuth({ adapter: PrismaAdapter(prisma), providers: [ CredentialsProvider({ name: "Credentials", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { // Placeholder – real logic will be in Chapter 2 return null; }, }), GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), LineProvider({ clientId: process.env.LINE_CLIENT_ID!, clientSecret: process.env.LINE_CLIENT_SECRET!, callbackUrl: process.env.NEXTAUTH_URL!, }), ], session: { strategy: "jwt" }, callbacks: { async session({ session, token }) { session.user.id = token.sub; return session; }, }, secret: process.env.NEXTAUTH_SECRET, }); -
Set environment variables
Create.env.local:DATABASE_URL=postgresql://user:pass@localhost:5432/nextauth_demo NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=supersecretkey GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret LINE_CLIENT_ID=your-line-client-id LINE_CLIENT_SECRET=your-line-client-secret -
Run the dev server
npm run dev
You should now see /api/auth/signin rendering a login page with all providers.
Chapter 2 – Credentials Provider: Email / Password Login
What
The Credentials Provider allows us to implement a traditional email‑password flow. Unlike OAuth, it requires us to manage user passwords securely, typically via hashing and salting.
Why
- User Control – Some customers prefer a native login experience.
- Compliance – Certain regulations (e.g., GDPR) may require explicit credential handling.
- Revenue – Offering a password option can increase conversion rates for SaaS products.
How
-
Add bcrypt for hashing
npm install bcryptjs -
Create a user service
Inlib/user.ts:import { prisma } from "./prisma"; import bcrypt from "bcryptjs"; export async function createUser(email: string, password: string) { const hashed = await bcrypt.hash(password, 12); return prisma.user.create({ data: { email, password: hashed }, }); } export async function findUserByEmail(email: string) { return prisma.user.findUnique({ where: { email } }); } export async function verifyPassword( input: string, storedHash: string ) { return bcrypt.compare(input, storedHash); } -
Update the Credentials Provider
In[...nextauth].ts:async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null; const user = await findUserByEmail(credentials.email); if (!user) return null; const valid = await verifyPassword(credentials.password, user.password); if (!valid) return null; return { id: user.id, email: user.email }; } -
Create a registration page
pages/register.tsx:import { useState } from "react"; import { useRouter } from "next/router"; export default function Register() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (res.ok) router.push("/api/auth/signin"); }; return ( <form onSubmit={handleSubmit}> <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <button type="submit">Register</button> </form> ); } -
Create the registration API
pages/api/auth/register.ts:import { NextApiRequest, NextApiResponse } from "next"; import { createUser } from "../../../lib/user"; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== "POST") return res.status(405).end(); const { email, password } = req.body; try { await createUser(email, password); res.status(201).json({ message: "User created" }); } catch (err) { res.status(500).json({ error: "Registration failed" }); } } -
Secure the password field – In
schema.prisma, add apasswordcolumn toUser:model User { id Int @id @default(autoincrement()) name String? email String? @unique emailVerified DateTime? image String? password String? accounts Account[] sessions Session[] } -
Run migrations
npx prisma migrate dev --name add-password
Now you have a fully functional email/password login flow integrated with NextAuth.
Chapter 3 – OAuth Providers: Google, GitHub, Line
What
OAuth providers let users authenticate via third‑party services. Each provider follows the OAuth 2.0 protocol but differs in scopes, callback URLs, and user data shape.
Why
- User Convenience – Users can log in with accounts they already trust.
- Reduced Password Fatigue – Fewer passwords to remember.
- Higher Conversion – Lower friction increases sign‑ups.
How
-
Google
- Scopes:
openid email profile - In
nextauthconfig, we already setclientIdandclientSecret. - Ensure the Google Cloud project has the correct OAuth consent screen and redirect URI (
http://localhost:3000/api/auth/callback/google).
- Scopes:
-
GitHub
- Scopes:
read:user user:email - Set up a GitHub OAuth app with the same redirect URI.
- Scopes:
-
Line
- Line’s OAuth flow requires a
callbackUrlin the provider config. - Register a Line Login channel and set the redirect URI to
http://localhost:3000/api/auth/callback/line.
- Line’s OAuth flow requires a
-
Testing
- Visit
/api/auth/signinand click each provider button. - Verify that the callback redirects to
/api/auth/sessionand that the session contains the provider data.
- Visit
-
Persisting Provider Data
NextAuth automatically stores provider account data in theAccounttable. You can query it via Prisma:const accounts = await prisma.account.findMany({ where: { userId: session.user.id }, }); -
Handling Provider‑Specific Claims
In thecallbacks.jwtcallback, you can add provider‑specific claims:async jwt({ token, account, profile }) { if (account?.provider === "google") { token.googleId = profile?.sub; } if (account?.provider === "github") { token.githubLogin = profile?.login; } return token; }
Chapter 4 – Database Session: Prisma + PostgreSQL
What
Storing sessions in a database (as opposed to JWT‑only) gives us:
- Server‑side revocation – Immediate logout across all devices.
- Audit trail – Track session creation, expiration, and IP addresses.
- Scalability – Works seamlessly with serverless functions and edge runtimes.
Why
- Security – Revoking sessions on compromise is critical.
- Compliance – Many regulations require audit logs.
- Business Insight – Session analytics can inform product decisions.
How
-
Configure NextAuth to use database sessions
In[...nextauth].ts:session: { strategy: "database" }, -
Add session fields – In
schema.prisma, theSessionmodel already exists. Add optional fields for analytics:model Session { id String @id @default(uuid()) sessionToken String @unique userId Int expires DateTime createdAt DateTime @default(now()) ipAddress String? userAgent String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) } -
Capture IP and User Agent – In the
callbacks.sessioncallback:async session({ session, token, user, req }) { session.user.id = token.sub; session.ipAddress = req?.headers["x-forwarded-for"]?.toString() ?? req?.socket.remoteAddress ?? null; session.userAgent = req?.headers["user-agent"] ?? null; return session; } -
Query Sessions – Example admin dashboard route:
const sessions = await prisma.session.findMany({ where: { userId: session.user.id }, orderBy: { createdAt: "desc" }, }); -
Revoking Sessions – To log out all devices:
await prisma.session.deleteMany({ where: { userId: session.user.id } });
Chapter 5 – Callbacks & JWT: Custom Claims & Session Shaping
What
Callbacks let us intercept and modify the data that flows through NextAuth. The most common callbacks are:
- jwt – Modify the JWT payload.
- session – Shape the session object returned to the client.
- signIn – Gate access based on custom logic.
Why
- Fine‑grained Authorization – Embed roles, permissions, or feature flags in the JWT.
- Performance – Reduce payload size by trimming unnecessary data.
- Business Rules – Enforce company policies (e.g., only allow users from a specific domain).
How
-
Add a
rolefield to the User modelmodel User { id Int @id @default(autoincrement()) name String? email String? @unique emailVerified DateTime? image String? password String? role String @default("user") accounts Account[] sessions Session[] } -
Update migration
npx prisma migrate dev --name add-role -
Set role during registration – In
createUser, assign a role based on email domain:const role = email.endsWith("@company.com") ? "admin" : "user"; return prisma.user.create({ data: { email, password: hashed, role }, }); -
Inject role into JWT
async jwt({ token, user }) { if (user) { token.role = user.role; } return token; } -
Expose role in session
async session({ session, token }) { session.user.role = token.role; return session; } -
Guard routes – In a protected page:
import { getSession } from "next-auth/react"; export default function Dashboard() { // ... } export async function getServerSideProps(context) { const session = await getSession(context); if (!session ||