Rohan Yeole - Homepage Rohan Yeole

React + Django REST API Integration: Authentication, CSRF, and State Management

Mar 15, 20261 min read

The two most common mistakes in a React + Django REST API integration are CSRF token handling and JWT storage in localStorage. Both are security issues, and both stem from tutorials that optimize for simplicity over correctness. This guide does it right.

Django Backend Setup

Install and configure DRF + SimpleJWT

pip install djangorestframework djangorestframework-simplejwt django-cors-headers
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "corsheaders",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",  # Must be before CommonMiddleware
    "django.middleware.common.CommonMiddleware",
    # ...
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

from datetime import timedelta
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "AUTH_COOKIE": "access_token",  # Custom cookie name
    "AUTH_COOKIE_HTTP_ONLY": True,
    "AUTH_COOKIE_SECURE": True,  # HTTPS only
    "AUTH_COOKIE_SAMESITE": "Lax",
}

# CORS: Allow your React app's origin
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # Development
    "https://app.yoursite.com",  # Production
]
CORS_ALLOW_CREDENTIALS = True  # Required for cookies

Token views

# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("api/auth/login/", TokenObtainPairView.as_view()),
    path("api/auth/refresh/", TokenRefreshView.as_view()),
    path("api/auth/logout/", LogoutView.as_view()),
]

JWT Storage: Cookies vs localStorage

The most common mistake: storing JWT access tokens in localStorage. localStorage is accessible by JavaScript — including injected scripts from XSS attacks. A token in localStorage is a stolen token waiting to happen.

The correct approach: HttpOnly cookies. The browser sends them automatically with every request to the same origin, and JavaScript cannot read them.

Custom token views that set HttpOnly cookies:

# views.py
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework.response import Response

class CookieTokenObtainPairView(TokenObtainPairView):
    def post(self, request, *args, **kwargs):
        response = super().post(request, *args, **kwargs)
        if response.status_code == 200:
            access = response.data.pop("access")
            response.set_cookie(
                key="access_token",
                value=access,
                httponly=True,
                secure=True,
                samesite="Lax",
                max_age=1800,  # 30 minutes
            )
        return response

React Frontend: Axios Setup

Install Axios:

npm install axios

Create an Axios instance with the right defaults:

// src/api/client.js
import axios from "axios";

const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL || "http://localhost:8000",
  withCredentials: true,  // Send cookies with every request
  headers: {
    "Content-Type": "application/json",
  },
});

export default apiClient;

withCredentials: true tells the browser to include cookies (including HttpOnly ones) in cross-origin requests. This is required for cookie-based auth to work with CORS.

Token Refresh with Axios Interceptors

Access tokens expire. Instead of logging the user out on 401, transparently refresh the token:

// src/api/client.js
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    error ? prom.reject(error) : prom.resolve(token);
  });
  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(() => apiClient(originalRequest))
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        await apiClient.post("/api/auth/refresh/");
        processQueue(null);
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        // Refresh failed — user must log in again
        window.location.href = "/login";
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

The queue ensures that if multiple requests fail simultaneously with 401, only one refresh request is sent. All failed requests retry after the refresh completes.

Auth State Management

For most applications, React Context is sufficient for auth state:

// src/contexts/AuthContext.js
import { createContext, useContext, useState, useCallback } from "react";
import apiClient from "../api/client";

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  const login = useCallback(async (email, password) => {
    const response = await apiClient.post("/api/auth/login/", { email, password });
    setUser(response.data.user);
  }, []);

  const logout = useCallback(async () => {
    await apiClient.post("/api/auth/logout/");
    setUser(null);
  }, []);

  const loadUser = useCallback(async () => {
    try {
      const response = await apiClient.get("/api/auth/me/");
      setUser(response.data);
    } catch {
      setUser(null);
    }
  }, []);

  return (
    <AuthContext.Provider value={{ user, login, logout, loadUser }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);

Wrap your app:

// src/index.js
import { AuthProvider } from "./contexts/AuthContext";

root.render(
  <AuthProvider>
    <App />
  </AuthProvider>
);

CSRF for Non-JWT Endpoints

If you use Django's session auth (instead of JWT) for some endpoints, CSRF protection applies. Get the CSRF token and include it in requests:

// Get CSRF token from cookie
function getCsrfToken() {
  return document.cookie
    .split("; ")
    .find((row) => row.startsWith("csrftoken="))
    ?.split("=")[1];
}

// Include in Axios
apiClient.defaults.headers.common["X-CSRFToken"] = getCsrfToken();

Or use Axios interceptors to add it dynamically:

apiClient.interceptors.request.use((config) => {
  if (["post", "put", "patch", "delete"].includes(config.method)) {
    config.headers["X-CSRFToken"] = getCsrfToken();
  }
  return config;
});

Making API Calls

With the setup complete, making API calls is straightforward:

// src/hooks/useProjects.js
import { useState, useEffect } from "react";
import apiClient from "../api/client";

export const useProjects = () => {
  const [projects, setProjects] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    apiClient
      .get("/api/projects/")
      .then((res) => setProjects(res.data.results))
      .catch((err) => setError(err.response?.data?.detail))
      .finally(() => setLoading(false));
  }, []);

  return { projects, loading, error };
};

If you need a React frontend integrated with a Django REST API — authentication done securely, state management set up correctly, and the CORS configuration working — hire me as a React developer or as a Django developer for the full stack.

Chat with me on WhatsApp