Аутентификацию в Next.js регулярно пишут сами — «там же всего-то JWT в куке». Написать действительно несложно; сложно потом ответить на вопросы, которые появляются через месяц: как отозвать доступ у уволенного сотрудника, что делать при краже токена, как разлогинить пользователя со всех устройств.
Разберём оба подхода и, главное, тот размен, который определяет выбор между ними.
Два подхода к аутентификации
В Next.js App Router есть два популярных подхода:
- NextAuth.js (Auth.js) — готовая библиотека с провайдерами
- Своя реализация — JWT/session в cookies
NextAuth.js недавно переименован в Auth.js и поддерживает не только Next.js. Но пакет next-auth всё ещё актуален для Next.js.
NextAuth.js — быстрый старт
Установка
npm install next-auth@betaКонфигурация
// 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
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;Использование
// 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
// 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
// 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('/');
}Форма логина
// 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 отвечает на вопрос «куда отправить пользователя», авторизация в обработчике — на вопрос «можно ли ему эти данные».
// 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 и пароль, без внешних провайдеров
- Нестандартная логика: свой второй фактор, вход по ссылке, интеграция с существующей системой пользователей
- Нужен полный контроль над поведением
- Критичен размер бандла
Безопасность своей реализации:
- Используйте
httpOnlycookies - Хешируйте пароли (bcrypt, argon2)
- Валидируйте токены в middleware
- Rate limiting на login endpoint
- CSRF защита для форм
Refresh tokens
// 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 — правильный выбор. Своя реализация оправдана, когда нужна нестандартная логика или минимальный размер.
Чек-лист безопасности:
- Токен в
httpOnly-куке, а не вlocalStorage: содержимоеlocalStorageдоступно любому скрипту на странице, включая внедрённый через XSS. secure: trueв продакшене,sameSite: 'lax'как разумное умолчание (strictломает переходы по внешним ссылкам в авторизованную зону).- Пароли — bcrypt или argon2 с актуальной стоимостью. Проверьте, что хэширование выполняется на сервере: в Next.js граница между сервером и клиентом легко размывается, а криптография на клиенте бессмысленна.
- Ограничение частоты на эндпоинтах входа — по IP и по логину одновременно. Только по IP не спасёт от распределённого перебора, только по логину даст возможность заблокировать чужой аккаунт.
- Проверка прав там, где читаются данные, а не только в middleware.
- Секрет для подписи — из переменных окружения и разный для разных сред. Секрет из примера в репозитории — это чужая возможность выпустить себе токен администратора.
- Продуманный выход из системы. Для JWT это либо короткое время жизни, либо чёрный список — но в любом случае осознанное решение, а не «удалили куку и надеемся».
- Обработка ошибок без подсказок. «Неверный логин или пароль» вместо «пользователь не найден»: второй вариант позволяет перебором собрать список существующих учётных записей.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.