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

  1. Create the project

    npx create-next-app@latest nextauth-saas-demo --typescript
    cd nextauth-saas-demo
    
  2. Install dependencies

    npm install next-auth @next-auth/prisma-adapter prisma @prisma/client
    
  3. Initialize Prisma

    npx prisma init
    

    Edit prisma/schema.prisma to 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
    }
    
  4. Run migrations

    npx prisma migrate dev --name init
    
  5. Create the NextAuth API route
    Create pages/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,
    });
    
  6. 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
    
  7. 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

  1. Add bcrypt for hashing

    npm install bcryptjs
    
  2. Create a user service
    In lib/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);
    }
    
  3. 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 };
    }
    
  4. 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>
      );
    }
    
  5. 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" });
      }
    }
    
  6. Secure the password field – In schema.prisma, add a password column to User:

    model User {
      id            Int      @id @default(autoincrement())
      name          String?
      email         String?  @unique
      emailVerified DateTime?
      image         String?
      password      String?
      accounts      Account[]
      sessions      Session[]
    }
    
  7. 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

  1. Google

    • Scopes: openid email profile
    • In nextauth config, we already set clientId and clientSecret.
    • Ensure the Google Cloud project has the correct OAuth consent screen and redirect URI (http://localhost:3000/api/auth/callback/google).
  2. GitHub

    • Scopes: read:user user:email
    • Set up a GitHub OAuth app with the same redirect URI.
  3. Line

    • Line’s OAuth flow requires a callbackUrl in the provider config.
    • Register a Line Login channel and set the redirect URI to http://localhost:3000/api/auth/callback/line.
  4. Testing

    • Visit /api/auth/signin and click each provider button.
    • Verify that the callback redirects to /api/auth/session and that the session contains the provider data.
  5. Persisting Provider Data
    NextAuth automatically stores provider account data in the Account table. You can query it via Prisma:

    const accounts = await prisma.account.findMany({
      where: { userId: session.user.id },
    });
    
  6. Handling Provider‑Specific Claims
    In the callbacks.jwt callback, 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

  1. Configure NextAuth to use database sessions
    In [...nextauth].ts:

    session: { strategy: "database" },
    
  2. Add session fields – In schema.prisma, the Session model 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)
    }
    
  3. Capture IP and User Agent – In the callbacks.session callback:

    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;
    }
    
  4. Query Sessions – Example admin dashboard route:

    const sessions = await prisma.session.findMany({
      where: { userId: session.user.id },
      orderBy: { createdAt: "desc" },
    });
    
  5. 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

  1. Add a role field to the User model

    model 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[]
    }
    
  2. Update migration

    npx prisma migrate dev --name add-role
    
  3. 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 },
    });
    
  4. Inject role into JWT

    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    }
    
  5. Expose role in session

    async session({ session, token }) {
      session.user.role = token.role;
      return session;
    }
    
  6. 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 ||