Главная/Статьи/Аутентификация в Next.js — NextAuth.js vs своё решение

Аутентификация в Next.js — NextAuth.js vs своё решение

Сравниваем NextAuth.js (Auth.js) и кастомную JWT-аутентификацию. Когда что использовать, подводные камни, production-ready примеры.

ДМ
Дмитрий Мещеряков
📅 31 июля 2026 г.📖 10 мин чтения

Аутентификацию в Next.js регулярно пишут сами — «там же всего-то JWT в куке». Написать действительно несложно; сложно потом ответить на вопросы, которые появляются через месяц: как отозвать доступ у уволенного сотрудника, что делать при краже токена, как разлогинить пользователя со всех устройств.

Разберём оба подхода и, главное, тот размен, который определяет выбор между ними.

Два подхода к аутентификации

В Next.js App Router есть два популярных подхода:

  1. NextAuth.js (Auth.js) — готовая библиотека с провайдерами
  2. Своя реализация — JWT/session в cookies
💡 Совет

NextAuth.js недавно переименован в Auth.js и поддерживает не только Next.js. Но пакет next-auth всё ещё актуален для Next.js.

NextAuth.js — быстрый старт

Установка

bash
npm install next-auth@beta

Конфигурация

typescript
// src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    // OAuth провайдеры
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    // Email + пароль
    Credentials({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Пароль', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
        if (!user || !user.password) {
          return null;
        }
        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.password
        );
        if (!isValid) {
          return null;
        }
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
  session: {
    strategy: 'jwt', // или 'database' для сессий в БД
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
  pages: {
    signIn: '/login',
    error: '/login',
  },
});

Route handlers

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

Использование

typescript
// Server Component
import { auth } from '@/auth';
export default async function Dashboard() {
  const session = await auth();
  if (!session) {
    redirect('/login');
  }
  return <div>Привет, {session.user.name}!</div>;
}
// Client Component
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
export function AuthButton() {
  const { data: session, status } = useSession();
  if (status === 'loading') {
    return <div>Загрузка...</div>;
  }
  if (session) {
    return (
      <div>
        <span>{session.user?.name}</span>
        <button onClick={() => signOut()}>Выйти</button>
      </div>
    );
  }
  return <button onClick={() => signIn()}>Войти</button>;
}

Своя реализация — JWT в cookies

⚠️ Важно

Ключевое свойство JWT, которое нужно понимать до того, как вы его выберете: подписанный токен нельзя отозвать. Пока он не истёк, он валиден — сервер проверяет подпись, а не наличие сессии в хранилище. Заблокировали пользователя, сменили ему права, обнаружили утечку токена — всё это никак не влияет на уже выданный токен.

Отсюда обычные последствия: либо время жизни делают коротким (15 минут) и вводят refresh-токены с их собственной сложностью, либо ведут список отозванных токенов — то есть возвращаются к хранилищу сессий, ради отказа от которого JWT и брали.

Это не аргумент против JWT: для многих проектов «доступ пропадёт в течение 15 минут» — приемлемо. Но решение должно быть осознанным, а не следствием того, что в примере из статьи было написано expiresIn: '7d'.

Утилиты для JWT

typescript
// src/lib/auth.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const COOKIE_NAME = 'auth-token';
type JWTPayload = {
  userId: string;
  email: string;
  role: string;
};
export async function createToken(payload: JWTPayload): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload as unknown as JWTPayload;
  } catch {
    return null;
  }
}
export async function getSession(): Promise<JWTPayload | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get(COOKIE_NAME)?.value;
  if (!token) return null;
  return verifyToken(token);
}
export async function setAuthCookie(token: string): Promise<void> {
  const cookieStore = await cookies();
  cookieStore.set(COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 дней
    path: '/',
  });
}
export async function removeAuthCookie(): Promise<void> {
  const cookieStore = await cookies();
  cookieStore.delete(COOKIE_NAME);
}

Server Actions для auth

typescript
// src/app/actions/auth.ts
'use server';
import { redirect } from 'next/navigation';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { createToken, setAuthCookie, removeAuthCookie } from '@/lib/auth';
const loginSchema = z.object({
  email: z.string().email('Некорректный email'),
  password: z.string().min(6, 'Минимум 6 символов'),
});
const registerSchema = loginSchema.extend({
  name: z.string().min(2, 'Минимум 2 символа'),
});
export type AuthState = {
  error?: string;
  success?: boolean;
};
export async function login(
  prevState: AuthState,
  formData: FormData
): Promise<AuthState> {
  const result = loginSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  });
  if (!result.success) {
    return { error: result.error.issues[0].message };
  }
  const { email, password } = result.data;
  const user = await prisma.user.findUnique({
    where: { email },
  });
  if (!user || !user.password) {
    return { error: 'Неверный email или пароль' };
  }
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    return { error: 'Неверный email или пароль' };
  }
  const token = await createToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  });
  await setAuthCookie(token);
  redirect('/dashboard');
}
export async function register(
  prevState: AuthState,
  formData: FormData
): Promise<AuthState> {
  const result = registerSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
    name: formData.get('name'),
  });
  if (!result.success) {
    return { error: result.error.issues[0].message };
  }
  const { email, password, name } = result.data;
  const existing = await prisma.user.findUnique({
    where: { email },
  });
  if (existing) {
    return { error: 'Email уже занят' };
  }
  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await prisma.user.create({
    data: {
      email,
      password: hashedPassword,
      name,
    },
  });
  const token = await createToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  });
  await setAuthCookie(token);
  redirect('/dashboard');
}
export async function logout(): Promise<void> {
  await removeAuthCookie();
  redirect('/');
}

Форма логина

typescript
// src/app/login/page.tsx
'use client';
import { useActionState } from 'react';
import { login, type AuthState } from '@/app/actions/auth';
export default function LoginPage() {
  const [state, formAction, pending] = useActionState<AuthState, FormData>(
    login,
    {}
  );
  return (
    <div className="login-page">
      <h1>Вход</h1>
      <form action={formAction}>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            id="email"
            name="email"
            type="email"
            required
            autoComplete="email"
          />
        </div>
        <div className="form-group">
          <label htmlFor="password">Пароль</label>
          <input
            id="password"
            name="password"
            type="password"
            required
            autoComplete="current-password"
          />
        </div>
        {state.error && (
          <div className="error">{state.error}</div>
        )}
        <button type="submit" disabled={pending}>
          {pending ? 'Вход...' : 'Войти'}
        </button>
      </form>
    </div>
  );
}

Middleware для защиты роутов

⚠️ Важно

Middleware — это удобство, а не граница безопасности. Он хорош для редиректа неавторизованного пользователя на страницу входа, но полагаться только на него нельзя: он защищает маршруты, а данные отдают обработчики и серверные компоненты. Проверка прав должна быть там, где данные читаются, — иначе прямое обращение к API-маршруту в обход навигации отдаст всё что угодно.

Практическое правило: middleware отвечает на вопрос «куда отправить пользователя», авторизация в обработчике — на вопрос «можно ли ему эти данные».

typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
const authRoutes = ['/login', '/register'];
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get('auth-token')?.value;
  // Проверяем токен
  let isAuthenticated = false;
  if (token) {
    try {
      await jwtVerify(token, JWT_SECRET);
      isAuthenticated = true;
    } catch {
      // Токен невалиден
    }
  }
  // Защищённые роуты
  if (protectedRoutes.some((route) => pathname.startsWith(route))) {
    if (!isAuthenticated) {
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('redirect', pathname);
      return NextResponse.redirect(loginUrl);
    }
  }
  // Страницы авторизации для залогиненных
  if (authRoutes.some((route) => pathname.startsWith(route))) {
    if (isAuthenticated) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }
  return NextResponse.next();
}
export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*', '/settings/:path*', '/login', '/register'],
};

Сравнение подходов

КритерийNextAuth.jsСвоя реализация
Время настройки30 мин2-4 часа
OAuth провайдерыИз коробкиРуками
КастомизацияОграниченаПолная
Размер бандла+50KB~5KB
ОбновленияСледить за breaking changesВы контролируете
ТипизацияХорошаяИдеальная (своя)

Когда что выбрать

NextAuth.js

  • Нужны OAuth-провайдеры (Google, GitHub и подобные)
  • Стандартный сценарий авторизации
  • Быстрый запуск MVP
  • Не хотите принимать решения по безопасности самостоятельно — а их в этой теме много, и цена ошибки высокая

Своя реализация

  • Только email и пароль, без внешних провайдеров
  • Нестандартная логика: свой второй фактор, вход по ссылке, интеграция с существующей системой пользователей
  • Нужен полный контроль над поведением
  • Критичен размер бандла
⚠️ Важно

Безопасность своей реализации:

  • Используйте httpOnly cookies
  • Хешируйте пароли (bcrypt, argon2)
  • Валидируйте токены в middleware
  • Rate limiting на login endpoint
  • CSRF защита для форм

Refresh tokens

typescript
// src/lib/auth.ts
export async function createTokenPair(payload: JWTPayload) {
  const accessToken = await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('15m')
    .sign(JWT_SECRET);
  const refreshToken = await new SignJWT({ userId: payload.userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .sign(REFRESH_SECRET);
  return { accessToken, refreshToken };
}
// API route для refresh
export async function POST(request: Request) {
  const { refreshToken } = await request.json();
  try {
    const { payload } = await jwtVerify(refreshToken, REFRESH_SECRET);
    const user = await prisma.user.findUnique({
      where: { id: payload.userId as string },
    });
    if (!user) {
      return Response.json({ error: 'User not found' }, { status: 401 });
    }
    const tokens = await createTokenPair({
      userId: user.id,
      email: user.email,
      role: user.role,
    });
    return Response.json(tokens);
  } catch {
    return Response.json({ error: 'Invalid token' }, { status: 401 });
  }
}

Итоги

Для большинства проектов NextAuth.js — правильный выбор. Своя реализация оправдана, когда нужна нестандартная логика или минимальный размер.

Чек-лист безопасности:

  1. Токен в httpOnly-куке, а не в localStorage: содержимое localStorage доступно любому скрипту на странице, включая внедрённый через XSS.
  2. secure: true в продакшене, sameSite: 'lax' как разумное умолчание (strict ломает переходы по внешним ссылкам в авторизованную зону).
  3. Пароли — bcrypt или argon2 с актуальной стоимостью. Проверьте, что хэширование выполняется на сервере: в Next.js граница между сервером и клиентом легко размывается, а криптография на клиенте бессмысленна.
  4. Ограничение частоты на эндпоинтах входа — по IP и по логину одновременно. Только по IP не спасёт от распределённого перебора, только по логину даст возможность заблокировать чужой аккаунт.
  5. Проверка прав там, где читаются данные, а не только в middleware.
  6. Секрет для подписи — из переменных окружения и разный для разных сред. Секрет из примера в репозитории — это чужая возможность выпустить себе токен администратора.
  7. Продуманный выход из системы. Для JWT это либо короткое время жизни, либо чёрный список — но в любом случае осознанное решение, а не «удалили куку и надеемся».
  8. Обработка ошибок без подсказок. «Неверный логин или пароль» вместо «пользователь не найден»: второй вариант позволяет перебором собрать список существующих учётных записей.
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.