Category: Authentication and Security
Primary Keyword: Google OAuth email authentication Next.js SaaS
Level: Beginner to Intermediate
Authentication is the front door of your SaaS. Get it wrong and users can't get in — or worse, the wrong users get in. Get it right and it becomes invisible: a smooth, trusted experience that sets the tone for everything that follows.
This guide covers the full authentication setup for a Next.js App Router SaaS: email and password login, Google OAuth, session management, protected routes, and secure API access. By the end, you'll have a production-ready auth layer you can drop into any project.
Why Authentication Is More Than Just a Login Form
Most tutorials show you how to render a login form. But in a real SaaS, authentication touches nearly every layer of your app:
- Signup — creating a user record in your database
- Login — verifying credentials and issuing a session
- OAuth — delegating identity verification to Google
- Session — persisting who is logged in across requests
- Route protection — blocking unauthenticated access to the dashboard
- API protection — securing your backend routes from anonymous requests
- Role handling — distinguishing free users from paid subscribers
NextAuth.js (now Auth.js) handles most of this out of the box. Your job is to configure it correctly and wire it to your database and UI.
Setting Up NextAuth.js in the App Router
Start by installing the required packages:
npm install next-auth@beta @auth/mongodb-adapter mongoose bcryptjs
Note: Use
next-auth@betafor full App Router support. The stable v4 has limited support for the App Router's server components and route handlers.
Create your auth configuration file:
// lib/authOptions.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import clientPromise from '@/lib/mongodbClient';
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
import bcrypt from 'bcryptjs';
export const authOptions = {
adapter: MongoDBAdapter(clientPromise),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
await connectDB();
const user = await User.findOne({ email: credentials.email });
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(credentials.password, user.password);
if (!isValid) return null;
return { id: user._id.toString(), email: user.email, name: user.name };
},
}),
],
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
async session({ session, token }) {
if (token) session.user.id = token.id;
return session;
},
},
pages: {
signIn: '/auth/login',
error: '/auth/error',
},
secret: process.env.NEXTAUTH_SECRET,
};
export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
Then expose the route handler:
// app/api/auth/[...nextauth]/route.js
import { handlers } from '@/lib/authOptions';
export const { GET, POST } = handlers;
This single file handles every auth request — login, logout, OAuth callbacks, session checks — automatically.
Configuring Environment Variables
Add these to your .env.local:
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_random_secret_here
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
MONGODB_URI=mongodb+srv://...
To generate a secure NEXTAUTH_SECRET:
openssl rand -base64 32
For GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, head to console.cloud.google.com, create a project, enable the Google OAuth API, and add http://localhost:3000/api/auth/callback/google as an authorized redirect URI.
The User Model: Bridging Auth and Your Database
NextAuth's MongoDB adapter automatically creates accounts, sessions, and verification_tokens collections. But you also need a users collection that your app controls — for subscription status, preferences, and other SaaS-specific data.
// models/User.js
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema(
{
name: { type: String },
email: { type: String, required: true, unique: true },
password: { type: String }, // null for OAuth users
image: { type: String },
stripeCustomerId: { type: String },
subscriptionStatus: {
type: String,
enum: ['active', 'past_due', 'canceled', 'trialing', null],
default: null,
},
role: { type: String, enum: ['user', 'admin'], default: 'user' },
},
{ timestamps: true }
);
export default mongoose.models.User || mongoose.model('User', UserSchema);
Key design decision: OAuth users have no password field. Email users have a hashed password. Your authorize function in CredentialsProvider checks for user.password before attempting bcrypt comparison — this prevents OAuth-only accounts from logging in via the credentials form.
Building the Signup Flow for Email Users
OAuth handles its own user creation automatically. For email/password, you need a signup API route:
// app/api/auth/signup/route.js
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
import bcrypt from 'bcryptjs';
export async function POST(req) {
await connectDB();
const { name, email, password } = await req.json();
if (!email || !password || password.length < 8) {
return Response.json({ error: 'Invalid input' }, { status: 400 });
}
const existing = await User.findOne({ email });
if (existing) {
return Response.json({ error: 'Email already registered' }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
await User.create({ name, email, password: hashed });
return Response.json({ success: true }, { status: 201 });
}
A few things worth noting here:
- Always hash passwords with bcrypt before storing. Never store plaintext.
- Salt rounds of 12 is the current recommended minimum for production.
- Return generic errors on failure — don't confirm whether an email exists to prevent enumeration attacks.
Building the Login and Signup UI
With NextAuth configured, your login page just needs to call signIn:
// app/auth/login/page.jsx
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
async function handleEmailLogin(e) {
e.preventDefault();
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
} else {
router.push('/dashboard');
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-base-200">
<div className="card w-full max-w-sm shadow-xl bg-base-100">
<div className="card-body">
<h2 className="card-title text-2xl font-bold">Sign in</h2>
{error && <div className="alert alert-error text-sm">{error}</div>}
<form onSubmit={handleEmailLogin} className="flex flex-col gap-3">
<input
type="email"
placeholder="Email"
className="input input-bordered"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
className="input input-bordered"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" className="btn btn-primary">
Sign in with Email
</button>
</form>
<div className="divider">OR</div>
<button
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
className="btn btn-outline gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
<p className="text-center text-sm mt-2">
Don't have an account?{' '}
<a href="/auth/signup" className="link link-primary">Sign up</a>
</p>
</div>
</div>
</div>
);
}
This gives you a clean DaisyUI login card with both authentication methods, error handling, and a redirect to the dashboard on success.
Protecting Routes with Middleware
The cleanest way to protect your dashboard is with Next.js middleware. It runs before any page renders and can redirect unauthenticated users server-side:
// middleware.js (root of project)
import { auth } from '@/lib/authOptions';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard && !isLoggedIn) {
return Response.redirect(new URL('/auth/login', req.nextUrl));
}
});
export const config = {
matcher: ['/dashboard/:path*'],
};
This is a single file that silently redirects any unauthenticated request to /dashboard to the login page — before a single byte of the dashboard renders. No client-side flicker, no layout shift.
Securing API Routes
For API routes in your SaaS backend, check the session at the top of every handler:
// app/api/user/profile/route.js
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/authOptions';
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
export async function GET(req) {
const session = await getServerSession(authOptions);
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
await connectDB();
const user = await User.findOne({ email: session.user.email }).select('-password');
return Response.json({ user });
}
Always exclude the password field when returning user data. The .select('-password') Mongoose modifier ensures it never leaves your server.
How This Fits Into the Zero to SaaS Journey
Authentication is the second major milestone in building a SaaS — right after project setup and right before connecting your database and billing layer. Without it, you can't identify who your users are, protect their data, or gate features behind a subscription.
If you're working through the Zero to SaaS course, this is where things start feeling real. You have a login page, a Google OAuth button, and a dashboard that only authenticated users can see. That's a real product foundation.
Once auth is working, the next step is wiring up Stripe subscriptions so you can start charging for access. Check out Build SaaS with Next.js and Stripe to continue the journey, or start from the beginning with the full Next.js SaaS tutorial.
Common Mistakes to Avoid
1. Using strategy: 'database' with the App Router
Database sessions require the adapter on every request, which adds latency. Use strategy: 'jwt' for serverless environments like Vercel — it's faster and scales better.
2. Not hashing passwords
This one is obvious but still happens. Always use bcrypt. Never MD5, never SHA-1, never plaintext.
3. Allowing credential login for OAuth-only accounts
If a user signed up with Google, they have no password. Your authorize function must check for user.password before calling bcrypt.compare, or it will throw a runtime error.
4. Forgetting to add the callback URL to Google Cloud Console
For production, you must add https://yourdomain.com/api/auth/callback/google to the authorized redirect URIs in Google Cloud. Missing this is the number one reason OAuth fails after deployment.
5. Exposing session data on the client without filtering
The JWT callback adds token.id to the session. Be deliberate about what ends up in the session object — don't forward sensitive fields like subscription details or internal IDs that clients don't need.
Pro Tips for Production
- Add email verification for new email signups using NextAuth's built-in email provider or a service like Resend.
-
Rate limit your signup and login routes to prevent brute force attacks. Upstash Redis with
@upstash/ratelimitis a clean serverless solution. -
Set
httpOnlyandsecurecookie flags — NextAuth does this by default in production whenNEXTAUTH_URLuses HTTPS. -
Log auth events — failed logins and new signups are worth tracking. Wire
events.signInandevents.createUserin your NextAuth config to a logging service. - Test OAuth locally with ngrok if you need a real HTTPS callback URL for development.
Real-World Example: DevTrack SaaS
You're building DevTrack — a time tracking SaaS for freelance developers. Here's how auth plays out for two different users:
User A — Sarah (Google OAuth):
Sarah clicks "Continue with Google," approves the OAuth consent screen, and lands on the dashboard. NextAuth creates her user record automatically via the MongoDB adapter. No password stored. Her name and image are pulled from her Google profile.
User B — James (Email/Password):
James fills out the signup form with his email and a password. Your API route hashes it with bcrypt and saves it to MongoDB. He then logs in with those credentials. NextAuth's CredentialsProvider verifies the hash and issues a JWT session.
Both users end up with a session that contains { id, email, name }. Your dashboard doesn't need to care how they authenticated — it just checks session.user.
Action Plan: What to Build Next
- ✅ Install
next-auth@beta,@auth/mongodb-adapter, andbcryptjs - ✅ Create
lib/authOptions.jswith Google and Credentials providers - ✅ Add all required environment variables to
.env.local - ✅ Set up Google OAuth credentials in Google Cloud Console
- ✅ Build the signup API route with bcrypt hashing
- ✅ Create the login page with email form and Google button
- ✅ Add middleware to protect
/dashboardroutes - ✅ Secure all API routes with
getServerSession - 🔜 Add email verification for new signups
- 🔜 Connect auth to your subscription status check
Wrapping Up
Authentication in a Next.js SaaS isn't complicated — but it has a lot of moving parts that need to work together. You now have both email/password and Google OAuth wired up, a MongoDB-backed user model, protected routes via middleware, and secure API handlers.
The pattern is consistent across every route and component: get the session, check it exists, use it to identify the user. Everything else — subscriptions, dashboards, roles — builds on top of this foundation.
If you want to build this complete authentication layer as part of a full SaaS app — including Stripe billing, a Tailwind dashboard, and deployment to Vercel — the Zero to SaaS course walks you through it all, step by step.
The door is open. Let your users in.



Top comments (0)