javascript

NextAuth + Django JWT без второй авторизации и ручного хаоса токенов

  • вторник, 7 апреля 2026 г. в 00:00:41
https://habr.com/ru/articles/1019856/

Во многих fullstack-проектах на Next.js и Django авторизация разваливается в одном и том же месте. На фронте удобно использовать NextAuth, потому что он закрывает формы входа, OAuth, серверную сессию и клиентские хуки. На бэкенде хочется иметь обычный JWT-контур на Django REST Framework, чтобы защищать API, работать с access и refresh токенами и не привязывать бизнес-логику к фронту. В итоге часто получается неприятная схема: пользователь логинится через NextAuth, потом отдельно логинится в Django, потом где-то вручную перекладываются токены, а через пару недель вся эта связка начинает ломаться на refresh, logout и OAuth.

Что делаем. Пользователь проходит один вход на фронте, а дальше фронт уже работает с токенами Django как с единственным источником доступа к API. Без второй формы входа, без ручного хранения access token в localStorage, без отдельного костыля под Google OAuth.

Разберем рабочую схему, в которой NextAuth отвечает за пользовательскую сессию на фронте, а Django остается владельцем API-авторизации и выдает JWT. На credentials-входе NextAuth сразу получает access и refresh от Django. На Google OAuth фронт сначала пускает пользователя через провайдера, потом синхронизирует его с Django и тоже получает пару токенов. После этого все запросы идут через один axios-клиент, который сам подставляет access token, сам обновляет его через refresh и сам завершает сессию, если refresh уже недействителен.

Где обычно начинается боль

Проблема в попытке заставить NextAuth или Django одновременно быть владельцами одной и той же авторизации. Если хранить фронтовую сессию отдельно, а Django-токены отдельно, почти сразу появляются перекосы. Пользователь на фронте как будто авторизован, но API уже отвечает 401. Google-вход срабатывает, но в Django такого пользователя еще нет. Refresh токен обновился на сервере, а на клиенте остался старый. После истечения access token часть экранов работает, часть уже падает. Logout вычищает cookie NextAuth, но не сбрасывает клиентское состояние и не убирает автоматический гостевой вход.

То есть задача здесь не вход как таковой, а единый auth bridge между двумя системами.

Рабочая архитектура

В рабочем варианте роли разделены так:

NextAuth отвечает за UI-вход, OAuth-провайдеров, серверную сессию и клиентский доступ к session через useSession и getServerSession.

Django отвечает за доменную авторизацию, хранение пользователя, проверку email, выдачу JWT, refresh и защиту API через JWTAuthentication.

Ключевой принцип - фронт не придумывает токены сам и не считает NextAuth источником API-доступа. Источник API-доступа только Django. NextAuth лишь хранит эти токены внутри своей сессии.

Бэкенд: кастомный пользователь и JWT по email

На стороне Django пользователь сделан email-first. Это важный момент, потому что при связке с OAuth и фронтовыми формами username только мешает.

# auth_app/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.contrib.auth.models import BaseUserManager


class CustomUserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError("The Email field must be set")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        return self.create_user(email, password, **extra_fields)


class User(AbstractUser):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100, blank=True)
    provider = models.CharField(max_length=50, default="credentials", blank=True, null=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    objects = CustomUserManager()

В settings.py указывается AUTH_USER_MODEL = "auth_app.User", включаются rest_framework_simplejwt, dj_rest_auth, allauth и dj_rest_auth.registration. Дальше Django работает как обычный JWT-бэкенд

Для credentials-входа используется кастомный TokenObtainPairSerializer, который аутентифицирует пользователя по email и password и добавляет полезные поля в токен.

# auth_app/serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        token["email"] = user.email
        token["name"] = user.name
        token["provider"] = user.provider
        return token

    def validate(self, attrs):
        credentials = {
            "email": attrs.get("email"),
            "password": attrs.get("password"),
        }
        return super().validate(credentials)

Сам login-view поверх этого сериализатора возвращает не только access и refresh, но и компактные данные пользователя. Это удобно, потому что фронту не нужно сразу после логина делать лишний запрос, чтобы узнать хотя бы id и name.

# auth_app/views.py
class CustomTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = serializer.user
        email_address = EmailAddress.objects.filter(user=user, email=user.email).first()
        if not email_address or not email_address.verified:
            raise AuthenticationFailed(detail="Email address is not verified.")

        return Response(
            {
                "access": serializer.validated_data["access"],
                "refresh": serializer.validated_data["refresh"],
                "user": {
                    "email": user.email,
                    "name": user.name,
                    "id": user.id,
                },
            },
            status=status.HTTP_200_OK,
        )

Здесь же встроена проверка подтверждения email. Это убирает еще один класс расхождений, когда фронт считает пользователя зарегистрированным, но бэкенд не должен пускать его в защищенные API.

Фронт: NextAuth как оболочка вокруг токенов Django

Главный узел. На стороне Next.js credentials-провайдер не проверяет пользователя сам. Он просто передает email и password в Django и забирает оттуда access, refresh и user.

// src/lib/auth/authOptions.ts
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import apiClient from "@/services/authClientService";

const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL;

export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) {
          throw new Error("Email and password must be provided");
        }

        const { data } = await apiClient.post(`${baseURL}/api/auth/custom/login/`, {
          email: credentials.email,
          password: credentials.password,
        });

        if (data?.user) {
          const { id, name, email } = data.user;
          return {
            id: id.toString(),
            name,
            email,
            accessToken: data.access || null,
            refreshToken: data.refresh || null,
          };
        }

        return null;
      },
    }),
  ],

Происходит разворот, после credentials-входа NextAuth не хранит абстрактного пользователя. Он хранит пользователя плюс токены Django. Значит дальше фронт и API уже живут в одной системе координат.

Google OAuth без второй авторизации

Обычно именно OAuth ломает архитектуру. Пользователь успешно вошел через Google, NextAuth считает, что все хорошо, а Django про такого пользователя еще ничего не знает. В итоге фронтовая сессия есть, а API не доступно.

Решение - после удачного OAuth-входа выполнить один backend bridge-запрос register-or-login. На этом шаге Django либо создает пользователя, либо обновляет существующего, после чего сразу выдает свою JWT-пару.

// src/lib/auth/authOptions.ts
callbacks: {
  async signIn({ user, account }) {
    const allowedProviders = ["google", "apple"];

    if (account?.provider && allowedProviders.includes(account.provider)) {
      const res = await apiClient.post(
        `${baseURL}/api/auth/custom/oauth/register-or-login/`,
        {
          id: user.id,
          name: user.name,
          email: user.email,
          provider: account.provider,
        }
      );

      if (res.status === 200) {
        user.accessToken = res.data.access;
        user.refreshToken = res.data.refresh;
        return true;
      }

      return false;
    }

    return true;
  },

На стороне Django это выглядит так:

# auth_app/views.py
class CustomOAuthRegisterOrLoginView(APIView):
    permission_classes = [AllowAny]

    def post(self, request, *args, **kwargs):
        serializer = OAuthUserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data

        provider = data["provider"]
        email = data["email"]
        name = data["name"]
        user_id = data["id"]

        user, created = User.objects.get_or_create(
            email=email,
            defaults={
                "name": name,
                "provider": provider,
                "username": user_id,
            },
        )

        if not created:
            user.name = name
            user.provider = provider
            user.save()

        refresh = RefreshToken.for_user(user)
        access = str(refresh.access_token)

        return Response(
            {
                "message": "User successfully synchronized.",
                "user": {
                    "email": user.email,
                    "name": user.name,
                    "provider": user.provider,
                },
                "access": access,
                "refresh": str(refresh),
            },
            status=status.HTTP_200_OK,
        )

В результате credentials и Google OAuth сходятся в один и тот же финал, у фронта есть сессия NextAuth, а внутри нее лежат токены Django. Это и есть точка, в которой пропадает вторая авторизация.

JWT и session callbacks: хранить только то, что реально нужно

Следующий частый перекос начинается в callbacks. Туда начинают складывать слишком много данных, потом session разрастается, фронт начинает полагаться на случайные поля, и через месяц уже непонятно, где истина.

Рабочее правило - в JWT и session стоит сохранять только то, что нужно для идентификации пользователя и доступа к API. В этом случае достаточно id, accessToken и refreshToken.

// src/lib/auth/authOptions.ts
  async jwt({ token, user }) {
    if (user) {
      token.id = user.id.toString();
      token.accessToken = user.accessToken || null;
      token.refreshToken = user.refreshToken || null;
    }
    return token;
  },

  async session({ session, token }) {
    session.user = {
      ...session.user,
      id: token.id as string,
    };
    session.accessToken = token.accessToken || null;
    session.refreshToken = token.refreshToken || null;
    return session;
  },
},

Именно эта минимальность дальше сильно упрощает код. NextAuth хранит только то, что действительно нужно приложению. Остальные пользовательские данные можно получать уже из защищенного API, где всегда лежит актуальное состояние.

Один axios-клиент вместо ручной передачи токенов по компонентам

Если после этого продолжать вручную пробрасывать accessToken по компонентам, архитектура все равно останется хрупкой. Поэтому следующий шаг обязателен - один apiClient, который знает, как взять текущую сессию, проверить access token, при необходимости обновить его через refresh и только потом отправить запрос.

// src/services/authClientService.ts
import axios from "axios";
import { getSession, signOut } from "next-auth/react";

const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL;

const isTokenExpired = (token: string) => {
  if (!token) return true;

  const parts = token.split(".");
  if (parts.length !== 3) return true;

  try {
    const payload = JSON.parse(atob(parts[1]));
    return !payload.exp || payload.exp * 1000 < Date.now();
  } catch {
    return true;
  }
};

const apiClient = axios.create({
  baseURL,
  headers: { "Content-Type": "application/json" },
});

apiClient.interceptors.request.use(async config => {
  const session = await getSession();

  if (session?.accessToken && isTokenExpired(session.accessToken)) {
    const { data } = await axios.post(
      `${baseURL}/api/auth/refresh/`,
      { refresh: session.refreshToken },
      { headers: { "Content-Type": "application/json" } }
    );

    session.accessToken = data.accessToken;
    session.refreshToken = data.refreshToken;
  }

  if (session?.accessToken) {
    config.headers["Authorization"] = `Bearer ${session.accessToken}`;
  }

  return config;
});

Это убирает сразу несколько проблем. Компоненты не знают ничего о refresh-механике. RTK Query не нужно отдельно обучать работе с токенами. Обычные сервисы тоже ничего не знают о сроке жизни access token. Для любого запроса достаточно использовать один и тот же клиент.

Что делать на 401

Даже с request interceptor этого мало. Токен мог стать невалидным между двумя запросами, пользователь мог быть разлогинен на сервере, refresh мог устареть. Поэтому нужен еще и response interceptor.

// src/services/authClientService.ts
apiClient.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      const session = await getSession();

      if (session?.refreshToken) {
        try {
          const { data } = await axios.post(
            `${baseURL}/api/auth/refresh/`,
            { refresh: session.refreshToken },
            { headers: { "Content-Type": "application/json" } }
          );

          session.accessToken = data.accessToken;
          session.refreshToken = data.refreshToken;
          originalRequest.headers["Authorization"] = `Bearer ${data.accessToken}`;

          return apiClient(originalRequest);
        } catch {
          signOut();
          return Promise.reject(error);
        }
      } else {
        signOut();
      }
    }

    return Promise.reject(error);
  }
);

Если access token протух, пробуем обновить его через refresh. Если refresh тоже невалиден, закрываем пользовательскую сессию. Это важно, потому что половинчатое состояние хуже полного logout. Пользователь видит интерфейс как будто живым, но запросы уже не проходят. Лучше один раз корректно завершить сессию, чем оставлять приложение в подвешенном состоянии.

Refresh endpoint на Django

Чтобы фронт не зависел от внутренностей SimpleJWT, полезно вынести refresh в собственный endpoint. Тогда формат ответа остается под контролем проекта, а дополнительные проверки можно добавить без переделки фронта.

# auth_app/views.py
class RefreshTokenView(APIView):
    permission_classes = [AllowAny]

    def post(self, request, *args, **kwargs):
        refresh_token = request.data.get("refresh")

        if not refresh_token:
            return Response(
                {"detail": "Refresh token is required."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            token = RefreshToken(refresh_token)

            if BlacklistedToken.objects.filter(token__jti=token["jti"]).exists():
                raise AuthenticationFailed(detail="Token has been blacklisted.")

            new_access_token = str(token.access_token)

            return Response(
                {
                    "accessToken": new_access_token,
                    "refreshToken": refresh_token,
                },
                status=status.HTTP_200_OK,
            )
        except TokenError as e:
            raise AuthenticationFailed(
                detail="Invalid or expired refresh token."
            ) from e

Такой endpoint делает две полезные вещи. Во-первых, фронт всегда получает один и тот же JSON с accessToken и refreshToken. Во-вторых, здесь можно централизованно проверить blacklist, а позже и любые собственные правила.

Почему не стоит класть access token в localStorage

На этом месте часто возникает соблазн упростить код и просто сохранить токены в localStorage. В небольшом pet-проекте это иногда кажется быстрым путем, но в реальном приложении это обычно приводит к ручному хаосу, от которого как раз хотелось уйти. Во-первых, фронт начинает жить своей жизнью отдельно от сессии. Во-вторых, logout и refresh приходится синхронизировать в двух местах. В-третьих, серверный рендер и клиентский рендер начинают видеть разное auth-состояние.

В этой схеме токены находятся внутри сессии NextAuth и доступны через getSession и getServerSession. Значит и клиент, и сервер читают один контур, а не два независимых.

Откуда брать актуальные данные пользователя

Еще одна полезная развилка. После логина не стоит надеяться, что все пользовательские данные всегда будут лежать в session. Для интерфейса обычно безопаснее иметь отдельный защищенный endpoint, который возвращает актуальное состояние пользователя.

# auth_app/views.py
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_user_data(request):
    user = request.user
    data = {
        "email": user.email,
        "name": user.name,
        "quantity": user.quantity,
    }
    return Response(data)

На фронте это позволяет работать проще. Сессия отвечает за auth-факт, а API отвечает за доменные данные.

// src/hooks/useUserSession.ts
export function useUserSession() {
  const { data: session, status } = useSession();
  const [userName, setUserName] = useState("🫥");

  useEffect(() => {
    if (!session) return;

    (async () => {
      try {
        const res = await apiClient.get("/api/auth/get-user-data/");
        setUserName(res.data.name || "🫥");
      } catch {
        setUserName("🫥");
      }
    })();
  }, [session]);

  return { session, userName, status };
}

Это выглядит как мелочь, но на длинной дистанции сильно разгружает архитектуру. Session не разрастается в неуправляемое хранилище, а реальные пользовательские данные берутся из API, где и должны жить.

Что получилось в итоге

В результате credentials и Google OAuth работают через один auth-контур. Пользователь входит один раз. Django остается владельцем JWT и API-доступа. NextAuth хранит только нужный минимум для фронта. Один axios-клиент автоматически подставляет access token, обновляет его через refresh и закрывает сессию, если дальнейшая работа уже невозможна.

С практической точки зрения это дает четыре важных эффекта. Нет второй авторизации. Нет ручной передачи токенов по компонентам. OAuth и обычный логин не расходятся по разным сценариям. Серверный и клиентский слой приложения читают одно и то же auth-состояние.

На этом месте Next.js + Django перестают спорить между собой за право быть главным. Каждый слой делает свое дело, а мост между ними остается коротким и понятным.

Когда такая схема особенно полезна

Этот подход хорошо работает, если на фронте нужен полноценный NextAuth-контур с провайдерами и серверной сессией, а на бэкенде уже есть или планируется обычный DRF API на JWT. Особенно он полезен там, где кроме логина есть еще личный кабинет, подписки, лимиты, роли, отдельные защищенные endpoints и несколько клиентских потоков, которые не хочется связывать вручную токен за токеном.

Если же проект совсем маленький и там нет OAuth, SSR-сессии и защищенного API-контра, можно обойтись проще. Но как только появляются NextAuth, Google login и Django JWT одновременно, без такого bridge-слоя архитектура обычно начинает расползаться очень быстро.

Для примеров в статье использован живой проект AI-Chat. Отдельная витрина на GitHub Pages пингует основной проект на спящем Render и содержит переход в рабочее приложение. В последовательной сборке с фронтом, бэкендом и их стыковкой этот контур можно посмотреть на Stepik курс AI на Django и Next II.