Building a Modern Authentication System in Next.js

June 19, 2024 (1y ago)

Building a Modern Authentication System in Next.js

Learn how to implement a secure, feature-rich authentication system in Next.js using NextAuth.js, including social logins, email verification, and role-based access control.

In this article:

Why Modern Authentication Matters

In today's digital landscape, a robust authentication system is crucial for:

Setting Up NextAuth.js

First, install the required dependencies:

npm install next-auth @prisma/client bcryptjs
npm install -D @types/bcryptjs

Create the NextAuth configuration:

// pages/api/auth/[...nextauth].ts
import { prisma } from '@/lib/prisma'
 
import { PrismaAdapter } from '@next-auth/prisma-adapter'
 
import { compare } from 'bcryptjs'
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import GithubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
 
export default NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_ID!,
      clientSecret: process.env.GOOGLE_SECRET!,
    }),
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('Invalid credentials')
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email },
        })
 
        if (!user || !user.password) {
          throw new Error('User not found')
        }
 
        const isValid = await compare(credentials.password, user.password)
 
        if (!isValid) {
          throw new Error('Invalid password')
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      },
    }),
  ],
  session: {
    strategy: 'jwt',
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (session?.user) {
        session.user.role = token.role
      }
      return session
    },
  },
  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
    verifyRequest: '/auth/verify-request',
  },
})

Implementing Authentication Features

1. Social Login Integration

Create a sign-in component that supports multiple providers:

// components/auth/SignInButtons.tsx
import { signIn } from 'next-auth/react'
 
export default function SignInButtons() {
  return (
    <div className="space-y-4">
      <button
        onClick={() => signIn('github')}
        className="w-full flex items-center justify-center gap-2 bg-black text-white p-2 rounded"
      >
        <GithubIcon />
        Continue with GitHub
      </button>
      <button
        onClick={() => signIn('google')}
        className="w-full flex items-center justify-center gap-2 bg-white text-black border p-2 rounded"
      >
        <GoogleIcon />
        Continue with Google
      </button>
    </div>
  )
}

2. Email Authentication

Implement email verification:

// lib/auth/email.ts
import { prisma } from '@/lib/prisma'
 
import { randomBytes } from 'crypto'
import { createTransport } from 'nodemailer'
 
export async function sendVerificationEmail(email: string) {
  const token = randomBytes(32).toString('hex')
  const expires = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
 
  await prisma.verificationToken.create({
    data: {
      identifier: email,
      token,
      expires,
    },
  })
 
  const transporter = createTransport({
    host: process.env.EMAIL_SERVER_HOST,
    port: Number(process.env.EMAIL_SERVER_PORT),
    auth: {
      user: process.env.EMAIL_SERVER_USER,
      pass: process.env.EMAIL_SERVER_PASSWORD,
    },
  })
 
  await transporter.sendMail({
    to: email,
    subject: 'Verify your email',
    html: `
      <p>Click the link below to verify your email:</p>
      <a href="${process.env.NEXTAUTH_URL}/auth/verify?token=${token}">
        Verify Email
      </a>
    `,
  })
}

3. Role-Based Access Control

Create a middleware to protect routes:

// middleware.ts
import { withAuth } from 'next-auth/middleware'
import { NextResponse } from 'next/server'
 
export default withAuth(
  function middleware(req) {
    const token = req.nextauth.token
    const isAdmin = token?.role === 'ADMIN'
    const isApiRoute = req.nextUrl.pathname.startsWith('/api')
 
    if (isApiRoute && !isAdmin) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token,
    },
  },
)
 
export const config = {
  matcher: ['/admin/:path*', '/api/admin/:path*'],
}

4. Session Management

Implement session handling:

// hooks/useSession.ts
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
 
export function useAuth() {
  const { data: session, status } = useSession()
  const router = useRouter()
 
  const requireAuth = () => {
    if (status === 'loading') return
    if (!session) {
      router.push('/auth/signin')
    }
  }
 
  const requireRole = (role: string) => {
    if (status === 'loading') return
    if (!session || session.user.role !== role) {
      router.push('/unauthorized')
    }
  }
 
  return {
    session,
    status,
    requireAuth,
    requireRole,
  }
}

Security Best Practices

  1. Use Environment Variables: Store sensitive data in .env.local
  2. Implement Rate Limiting: Prevent brute force attacks
  3. Enable CSRF Protection: Use NextAuth.js built-in protection
  4. Set Secure Cookies: Configure proper cookie settings
  5. Implement Password Policies: Enforce strong passwords
  6. Use HTTPS: Always use secure connections

Advanced Features

  1. Two-Factor Authentication:
// lib/auth/2fa.ts
import { authenticator } from 'otplib'
import QRCode from 'qrcode'
 
export async function setup2FA(userId: string) {
  const secret = authenticator.generateSecret()
  const user = await prisma.user.findUnique({
    where: { id: userId },
  })
 
  const otpauth = authenticator.keyuri(user.email, 'YourApp', secret)
 
  const qrCode = await QRCode.toDataURL(otpauth)
 
  await prisma.user.update({
    where: { id: userId },
    data: { twoFactorSecret: secret },
  })
 
  return { secret, qrCode }
}
  1. Session Management:
// lib/auth/session.ts
export async function revokeAllSessions(userId: string) {
  await prisma.session.deleteMany({
    where: { userId },
  })
}

Conclusion

Building a modern authentication system requires careful consideration of security, user experience, and maintainability. By following this guide, you can implement a robust authentication system that supports multiple providers, role-based access control, and advanced security features.

Remember to:


Let me know if you need help implementing any part of the authentication system!

Thanks for reading! 🚀