Implementing Authentication in Next.js 14 with Auth.js (NextAuth) v5: A Complete Guide

Introduction

Authentication is a crucial aspect of modern web applications. With Next.js 14 and Auth.js (formerly NextAuth.js) v5, implementing a robust authentication in Next.js has become more straightforward than ever. In this guide, we’ll explore how to create a production-ready authentication system that supports both email magic links and Google OAuth.

Key Features We’ll Implement

  • Email Magic Link Authentication
  • Google OAuth Integration
  • Role-Based Access Control (RBAC)
  • Account Linking/Unlinking
  • Username Management
  • Secure Session Handling
  • Automatic Database Cleanup

Prerequisites

Before we begin, ensure you have:

  • Node.js 14 or newer
  • PostgreSQL database
  • Google OAuth credentials
  • Email service provider (like Amazon SES, SendGrid, etc.)

Setting Up HTTPS for Local Development

For local development with authentication, HTTPS is required. You have two options:

Option 1: Using mkcert (Recommended)

# Install mkcert
brew install mkcert # macOS
sudo apt install mkcert # Ubuntu/Debian

# Setup local certificates
mkcert -install
mkcert localhost

Option 2: Using Next.js Experimental HTTPS

Enable experimental HTTPS in your package.json:

{
  "scripts": {
    "dev": "next dev --experimental-https"
  }
}

Database Setup

We’re using PostgreSQL with the following schema structure:

-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- User roles enum
CREATE TYPE "user_role" AS ENUM ('user', 'admin');

-- Core tables
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name VARCHAR(255),
    email VARCHAR(255) UNIQUE,
    role user_role DEFAULT 'user',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE accounts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    provider VARCHAR(255),
    provider_account_id VARCHAR(255),
    refresh_token TEXT,
    access_token TEXT,
    expires_at BIGINT,
    token_type VARCHAR(255),
    scope VARCHAR(255),
    id_token TEXT,
    session_state VARCHAR(255)
);

CREATE TABLE verification_tokens (
    identifier TEXT,
    token TEXT,
    expires TIMESTAMP,
    PRIMARY KEY (identifier, token)
);

Auth.js Configuration

Here’s the core configuration for Auth.js v5:

import { PrismaAdapter } from "@auth/prisma-adapter";
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Email from "next-auth/providers/email";

export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      allowDangerousEmailAccountLinking: true,
    }),
    Email({
      server: {
        host: process.env.EMAIL_SERVER_HOST,
        port: parseInt(process.env.EMAIL_SERVER_PORT!),
        auth: {
          user: process.env.EMAIL_SERVER_USER,
          pass: process.env.EMAIL_SERVER_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id;
        session.user.role = token.role;
      }
      return session;
    },
  },
});

Implementing Role-Based Access Control

RBAC can be implemented using middleware:

import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAdmin = req.auth?.user.role === "admin";

  // Protect admin routes
  if (req.nextUrl.pathname.startsWith("/admin")) {
    if (!isAdmin) {
      return NextResponse.redirect(new URL("/dashboard", req.url));
    }
  }

  // Protect authenticated routes
  if (req.nextUrl.pathname.startsWith("/dashboard")) {
    if (!isLoggedIn) {
      return NextResponse.redirect(new URL("/auth/signin", req.url));
    }
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Account Management Features

Linking/Unlinking Google Account

async function unlinkGoogleAccount(userId: string) {
  await prisma.account.deleteMany({
    where: {
      userId,
      provider: "google",
    },
  });
}

async function getAccountLinkStatus(userId: string) {
  const account = await prisma.account.findFirst({
    where: {
      userId,
      provider: "google",
    },
  });
  return !!account;
}

Automatic Token Cleanup

To keep your database clean, implement a cleanup function for expired verification tokens:

async function cleanupExpiredTokens() {
  await prisma.verificationToken.deleteMany({
    where: {
      expires: {
        lt: new Date(),
      },
    },
  });
}

Security Considerations

  1. Always use HTTPS in production
  2. Implement proper input validation
  3. Use secure session configuration
  4. Regular security audits
  5. Keep dependencies updated

Conclusion

With Auth.js v5 and Next.js 14, you can create a robust authentication system that handles various authentication methods while maintaining security and user experience. The combination of email magic links and social authentication provides flexibility for users while maintaining security standards.

Remember to check the official documentation for updates and best practices as both Next.js and Auth.js continue to evolve.

Share your love

Leave a Reply