NextAuth.js Security Guide for Vibe Coders

Share

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.

Tool & Platform Guides

NextAuth.js Security Guide for Vibe Coders