NextAuth.js Security Guide for Vibe Coders
Published on January 23, 2026 - 12 min read
TL;DR
NextAuth.js handles a lot of security automatically, but configuration mistakes can expose vulnerabilities. Always set a strong NEXTAUTH_SECRET, configure proper callback URLs (no wildcards), use the signIn and jwt callbacks to control access, and protect API routes with getServerSession. Database sessions are more secure than JWTs for sensitive applications.
Why NextAuth.js Security Matters for Vibe Coding
NextAuth.js (now Auth.js) is the most popular authentication library for Next.js. When AI tools generate NextAuth configuration, they often produce working auth flows but miss important security hardening. The defaults are good, but production apps need careful callback configuration and session validation.
Essential Environment Variables
# .env.local (never commit)
NEXTAUTH_URL=https://yourdomain.com
NEXTAUTH_SECRET=your-32-character-or-longer-secret-here
# OAuth provider secrets
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
NEXTAUTH_SECRET is Critical
The NEXTAUTH_SECRET is used to encrypt JWTs and session cookies. In production, NextAuth will throw an error if it's not set. Generate a strong secret with: openssl rand -base64 32. Never use a weak or guessable secret.
Secure Configuration
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
const handler = NextAuth({
adapter: PrismaAdapter(prisma), // Use database sessions for better security
providers: [
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
session: {
strategy: "database", // More secure than JWT
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
cookies: {
sessionToken: {
name: "__Secure-next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true, // Always true in production
},
},
},
callbacks: {
async signIn({ user, account, profile }) {
// Control who can sign in
// Return false to deny access
return true;
},
async session({ session, user }) {
// Add user ID to session
session.user.id = user.id;
return session;
},
},
});
export { handler as GET, handler as POST };
Callback Security
Callbacks control authentication flow. Use them to enforce security rules:
SignIn Callback
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
// Restrict to specific email domains
if (user.email && !user.email.endsWith("@yourcompany.com")) {
return false; // Deny sign in
}
// Block specific users
const blockedUsers = await getBlockedUsers();
if (blockedUsers.includes(user.email)) {
return false;
}
// Require email verification for credentials
if (account?.provider === "credentials") {
const dbUser = await prisma.user.findUnique({
where: { email: user.email },
});
if (!dbUser?.emailVerified) {
return "/auth/verify-email"; // Redirect to verification
}
}
return true;
},
}
JWT Callback (if using JWT strategy)
callbacks: {
async jwt({ token, user, account }) {
if (user) {
// Add user data to token on sign in
token.userId = user.id;
token.role = user.role;
}
// Check if token should be invalidated
// (e.g., user was banned, password changed)
if (token.userId) {
const dbUser = await prisma.user.findUnique({
where: { id: token.userId },
select: { tokenVersion: true, banned: true },
});
if (!dbUser || dbUser.banned) {
// Return empty token to force re-auth
return {};
}
// Check if token version changed (password reset, etc.)
if (dbUser.tokenVersion !== token.tokenVersion) {
return {};
}
}
return token;
},
}
Protecting API Routes
Always verify session server-side before processing requests:
// app/api/protected/route.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// User is authenticated
return Response.json({ userId: session.user.id });
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// Check authorization (e.g., admin role)
if (session.user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
// Process admin-only request...
}
Middleware Protection
// middleware.ts
import { withAuth } from "next-auth/middleware";
export default withAuth({
callbacks: {
authorized: ({ token, req }) => {
// Protect all routes under /dashboard
if (req.nextUrl.pathname.startsWith("/dashboard")) {
return !!token;
}
// Protect admin routes
if (req.nextUrl.pathname.startsWith("/admin")) {
return token?.role === "admin";
}
return true;
},
},
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*", "/api/:path*"],
};
CSRF Protection
NextAuth includes CSRF protection by default. Don't disable it:
// The csrfToken is automatically included in sign in forms
// For custom forms, include the CSRF token:
import { getCsrfToken } from "next-auth/react";
export default function SignIn() {
const csrfToken = await getCsrfToken();
return (
<form method="post" action="/api/auth/signin/email">
<input name="csrfToken" type="hidden" value={csrfToken} />
<input name="email" type="email" />
<button type="submit">Sign in</button>
</form>
);
}
Never Disable CSRF Protection
Some AI-generated code disables CSRF protection to "fix" issues. This exposes your app to cross-site request forgery attacks. If you're having CSRF issues, the problem is usually with form submission or CORS, not the protection itself.
Credentials Provider Security
If using email/password authentication, handle it carefully:
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcrypt";
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("Email and password required");
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.passwordHash) {
// Use same error for both cases to prevent enumeration
throw new Error("Invalid credentials");
}
const isValid = await bcrypt.compare(
credentials.password,
user.passwordHash
);
if (!isValid) {
// Log failed attempt for rate limiting
await logFailedAttempt(credentials.email);
throw new Error("Invalid credentials");
}
// Check account status
if (user.banned) {
throw new Error("Account suspended");
}
if (!user.emailVerified) {
throw new Error("Please verify your email");
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
})
NextAuth.js Security Checklist
- Strong NEXTAUTH_SECRET set (32+ characters)
- NEXTAUTH_URL matches your production domain
- Database sessions used for sensitive apps
- Session cookies are HTTP-only and Secure
- signIn callback validates allowed users
- API routes check session with getServerSession
- Middleware protects sensitive routes
- CSRF protection enabled (default)
- OAuth callback URLs are exact matches
- Credentials provider uses bcrypt and prevents enumeration
- Failed login attempts are logged/rate limited
- Session invalidation implemented for password changes
Should I use JWT or database sessions?
Database sessions are more secure because you can instantly invalidate them (e.g., when a user changes their password or is banned). JWTs are stateless and faster but can't be revoked until they expire. Use database sessions for apps with sensitive data.
::
Why is my CSRF token invalid?
Common causes: form not including csrfToken, CORS issues with custom domains, or using HTTP instead of HTTPS in production. Check that your NEXTAUTH_URL matches your actual domain and uses HTTPS.
How do I force users to re-authenticate?
With database sessions, delete the session from the database. With JWTs, use a token version stored in your database. Increment it when you need to invalidate tokens, and check it in the jwt callback.
Is it safe to expose user IDs in the session?
Yes, if you're using proper authorization checks. The user ID helps identify who's making requests. Just ensure you're checking that the user has permission to access the resources they're requesting, not just that they're logged in.
::
What CheckYourVibe Detects
When scanning your NextAuth.js project, CheckYourVibe identifies:
- Missing or weak NEXTAUTH_SECRET
- OAuth secrets in client-side code
- API routes without session verification
- Disabled CSRF protection
- Credentials provider without password hashing
- Missing rate limiting on login endpoints
Run npx checkyourvibe scan to catch these issues before they reach production.