← Blog
·9 min read

Auth.js Security Misconfigurations That Break Your App

Auth.js (NextAuth) has common security misconfigurations that expose sessions, tokens, and user data. Here's what they are and how to fix each one.

Rod

Founder & Developer

Auth.js security is one of those topics where the library does a lot right out of the box — and where a few small misconfigurations completely undermine it. We've scanned dozens of Next.js repos using Auth.js (v4 and v5) and found the same issues appearing repeatedly, usually in projects that are otherwise well-built.

These aren't theoretical vulnerabilities. Each one creates a real attack path.


Misconfiguration 1 — Weak or Missing AUTH_SECRET

This is the most critical Auth.js security issue. The AUTH_SECRET (called NEXTAUTH_SECRET in v4) is used to sign JWT tokens and encrypt session cookies. If it's weak or predictable, an attacker can forge valid sessions.

// BAD: Predictable secret — easily guessed or brute-forced
export const authOptions = {
  secret: "mysecret",
};
// BAD: No secret set — Auth.js generates one at startup
// Different secret on every server restart = all sessions invalidated
// On serverless: different secret per function instance = nothing works
export const authOptions = {
  providers: [...],
  // secret not set
};
// GOOD: Strong random secret from environment variable
export const authOptions: NextAuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  providers: [...],
};

Generate a proper secret:

# Generate a cryptographically random 64-character secret
openssl rand -base64 32

Paste the output as NEXTAUTH_SECRET in your .env file. In production, set it as an environment variable in Vercel, Railway, or wherever you deploy. Never commit it to your repo.


Misconfiguration 2 — Debug Mode Left On in Production

Auth.js's debug: true mode logs OAuth state parameters, access tokens, and session data to your server console. In production, those logs go to wherever your logs are aggregated — and everything in them is sensitive.

// BAD: Debug mode in production exposes tokens in logs
export const authOptions: NextAuthOptions = {
  debug: true,
  providers: [...],
};
// GOOD: Debug mode only in development
export const authOptions: NextAuthOptions = {
  debug: process.env.NODE_ENV === "development",
  providers: [...],
};

This is one of those configurations that's on during development, gets forgotten, and ships to production. Check yours now.


Misconfiguration 3 — Unvalidated Callback URLs

Auth.js validates callback URLs against your site URL by default. The problem comes when developers add custom sign-in pages or handle redirects manually without re-validating the destination.

// BAD: Trusting the callbackUrl from the query string without validation
export async function GET(req: Request) {
  const { callbackUrl } = Object.fromEntries(new URL(req.url).searchParams);
  // After sign-in, redirect here — but what if callbackUrl is https://evil.com?
  redirect(callbackUrl);
}

An open redirect vulnerability lets an attacker craft a link like: https://yourapp.com/api/auth/signin?callbackUrl=https://phishing-site.com

After signing in legitimately, the user lands on the attacker's site.

// GOOD: Validate the callback URL is on your own domain
function isSafeCallbackUrl(url: string): boolean {
  try {
    const parsed = new URL(url);
    return parsed.hostname === new URL(process.env.NEXTAUTH_URL!).hostname;
  } catch {
    return false;
  }
}
 
export async function GET(req: Request) {
  const { callbackUrl } = Object.fromEntries(new URL(req.url).searchParams);
  const safeUrl = isSafeCallbackUrl(callbackUrl) ? callbackUrl : "/dashboard";
  redirect(safeUrl);
}

Auth.js v5 improves this with stricter defaults — but custom redirect handling still requires your own validation.


Misconfiguration 4 — JWT Strategy Without Expiration

Auth.js defaults to JWT sessions in some configurations. JWT sessions are stateless — the server doesn't store them. That means you can't revoke them until they expire, even if the user's account is compromised.

// BAD: JWT sessions that last 30 days with no way to revoke them
export const authOptions: NextAuthOptions = {
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
};

If a session token is stolen, the attacker has 30-day access to your user's account. You can't invalidate it server-side — it's valid until it expires.

// BETTER: Short-lived JWT sessions
export const authOptions: NextAuthOptions = {
  session: {
    strategy: "jwt",
    maxAge: 24 * 60 * 60, // 1 day, not 30
  },
};
// BEST for apps that need instant revocation: database sessions
export const authOptions: NextAuthOptions = {
  session: {
    strategy: "database",
  },
  adapter: PrismaAdapter(prisma), // or your adapter of choice
};

Database sessions let you delete a session row to revoke access immediately. Useful for account deletion, security incidents, or forced sign-out.


Misconfiguration 5 — Callbacks That Trust User-Controlled Data

Auth.js callbacks let you customize what goes into tokens and sessions. A common pattern is adding user roles or permissions from the database into the JWT. Done wrong, it trusts data from the OAuth provider that an attacker could manipulate.

// BAD: Using provider-supplied data to set admin permissions
callbacks: {
  jwt({ token, profile }) {
    // Trusting the "role" field from the OAuth provider's response
    // An attacker controlling the provider could send role: "admin"
    token.role = profile?.role;
    return token;
  },
}
// GOOD: Look up permissions from your own database, not from the provider
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      // Only runs on sign-in — fetch role from your DB
      const dbUser = await db.user.findUnique({
        where: { id: user.id },
        select: { role: true },
      });
      token.role = dbUser?.role ?? "user";
    }
    return token;
  },
  session({ session, token }) {
    session.user.role = token.role as string;
    return session;
  },
}

Your database is the source of truth for roles and permissions. Not the OAuth provider's response.


Misconfiguration 6 — Missing CSRF Protection on Custom Sign-In

Auth.js handles CSRF protection for its built-in sign-in flow. If you build a custom sign-in page that posts directly to an endpoint, you may skip that protection.

// BAD: Custom form without CSRF token
export async function POST(req: Request) {
  const { email, password } = await req.json();
  // No CSRF check — any site can POST here as the user
  const result = await signIn("credentials", { email, password });
}
// GOOD: Use Auth.js's built-in signIn function from the client
// which includes the CSRF token automatically
import { signIn } from "next-auth/react";
 
await signIn("credentials", {
  email,
  password,
  redirect: false,
});

If you must use a fully custom flow, include and validate the CSRF token from Auth.js's /api/auth/csrf endpoint.


Misconfiguration 7 — Insecure Cookie Settings

Auth.js sets secure cookie flags automatically in production (secure: true, sameSite: "lax"). There are two ways developers accidentally break this.

First: setting cookies manually in middleware or API routes without the right flags.

// BAD: Session cookie without security flags
response.cookies.set("custom-session", value, {
  httpOnly: false,  // JavaScript can read this — enables XSS theft
  secure: false,    // Sent over HTTP — interceptable
  sameSite: "none", // Sent on cross-site requests — CSRF risk
});
// GOOD: Proper cookie flags
response.cookies.set("custom-session", value, {
  httpOnly: true,   // Not accessible from JavaScript
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",  // Sent on same-site + top-level navigations
  path: "/",
  maxAge: 60 * 60 * 24, // 24 hours
});

Second: running in development over HTTP and forgetting that NEXTAUTH_URL must match your actual protocol in production.

# .env.production — must use https://
NEXTAUTH_URL=https://yourapp.com
 
# .env.development — http is fine locally
NEXTAUTH_URL=http://localhost:3000

How to Audit Your Auth.js Setup

Reading this checklist is a start. Checking your actual configuration is the actual work.

Data Hogo scans for several of these misconfigurations automatically — weak secrets, debug mode in production, insecure session settings, and callback handling patterns. The security misconfiguration guide covers the broader OWASP category these fall into.

For the broken authentication OWASP category, which includes session handling and credential management, there's more depth on the attack patterns these misconfigurations enable.

Scan your Next.js repo for auth misconfigurations →


Quick Checklist

Before you ship:

  • AUTH_SECRET / NEXTAUTH_SECRET is a random 64-character string from environment variable
  • debug: false in production (or debug: process.env.NODE_ENV === "development")
  • Callback URLs are validated against your own domain
  • Session maxAge is 24 hours or less if using JWT
  • jwt and session callbacks read roles from your database, not the OAuth provider
  • Custom sign-in flows use Auth.js's built-in CSRF handling
  • Session cookies have httpOnly: true, secure: true (in production), sameSite: "lax"

Frequently Asked Questions

Is Auth.js (NextAuth) secure by default?

Auth.js is secure when configured correctly, but several defaults require explicit hardening. The most critical: you must set a strong, random AUTH_SECRET (NEXTAUTH_SECRET in v4). Without it, Auth.js uses an insecure default or throws an error in production. Callback URL validation, session strategy, and debug mode also need explicit configuration.

What is the AUTH_SECRET in Auth.js and how long should it be?

AUTH_SECRET (NEXTAUTH_SECRET in v4) is the key Auth.js uses to sign and encrypt JWT tokens and session cookies. It must be a random string of at least 32 characters — ideally 64. Generate it with openssl rand -base64 32. Never use a predictable value, and never hardcode it in source code.

Can NextAuth callback URLs be exploited for open redirect attacks?

Yes, if you don't restrict them. Auth.js validates callback URLs against your configured domain by default, but custom sign-in pages and improper redirect handling can introduce open redirect vulnerabilities. Always validate that callback URLs point to your own domain before redirecting.

Should I use JWT or database sessions in Auth.js?

Database sessions are more secure because you can revoke them server-side. JWT sessions are stateless — once issued, they're valid until they expire, even if the user's account is suspended or compromised. If you need instant session revocation (for security incidents or account deletion), use database sessions.

How do I debug Auth.js without exposing sensitive information?

Set debug: true only in development environments, never in production. Auth.js debug mode logs session tokens, OAuth state parameters, and provider responses to the console. In production, use server-side logging with Sentry or similar tools, and never log the raw token values.


Auth.js handles a lot of the hard parts of authentication for you. But it assumes you've configured it correctly. The misconfigurations above are subtle — the code runs fine, the login works, and nothing breaks until an attacker finds the gap. A quick audit now is worth more than an incident response later.

Scan your repo for security misconfigurations →

auth.jsnextauthsecurityjwtsessionoauthnext.jsauthentication