Logo

Robust Full-Stack Authentication with Django Allauth, Rest Framework, and React

Learn how to integrate django-allauth with React for a powerful and easy authentication system.

Published: 04 Sep, 2024


You’ve heard this phrase countless times: “Authentication is hard”.

That’s why teams rely on platforms like Auth0, or AWS Cognito. But these platforms come with complexity, added costs, and privacy issues because you have to send all your user’s identification information to a third party.

In this post, I’ll show you how you can leverage Django Allauth instead. The authentication will be as secure as all the platforms I mentioned, more private, cost much less money, have low latency because no external API requests will be needed to verify user credentials, while being completely customizable.

Clone this git repository if you want to see a complete example of the concepts from this post.

Why use Django Allauth

I believe that Allauth is the best way to add authentication to your full-stack app because it stands on the shoulders of a giant — Django’s built-in authentication framework.

Instead of re-inventing the wheel and subjecting you to buggy software, it allows you to take advantage of this battle tested tool in a modern context.

Under the hood, Allauth uses authentication tokens that work seamlessly with the Django’s session framework, where setup is mostly plug and play. Tools like Rest Framework will just work.

As a bonus, you’ll also be able to use the same setup if you decide to release a mobile application using the same API.

When not to use Allauth

Since it uses the session framework, you won’t be able to use Allauth in situations where you want to provide third parties access to your API.

It’s also not appropriate if you have more than one client side application spread across different domains. Because of how sessions work, both the Django backend and front-end application need to be hosted on the same domain scheme, such as app.mydomain.com. If your clients are spread across domains like mydomain.com, myotherdomain.com, yetanotherone.io, it will be impossible to get the session and security mitigations like CSRF to work.

In those cases, you need to look into implementing OAuth2 instead.

Setting up Headless Django Allauth

We will use Django Alllauth in headless mode. It will provide us with an API to manage authentication tokens for the client application.

Follow the installation instructions here.

You will also setup the authentication back-end, and the middle-ware:

AUTHENTICATION_BACKENDS = [
    # Needed to login by username in Django admin, regardless of `allauth`
    'django.contrib.auth.backends.ModelBackend',

    # `allauth` specific authentication methods, such as login by email
    'allauth.account.auth_backends.AuthenticationBackend',
]

MIDDLEWARE = (
    ...
    # Add the account middleware:
    "allauth.account.middleware.AccountMiddleware",
)

Since we’re using React instead of Django Templates, go ahead and turn on headless only mode:

HEADLESS_ONLY = True

You also need to setup django-cors-headers like so:

INSTALLED_APPS = [
...
"corsheaders",
...
]

MIDDLEWARE = [
...
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
...
]

CORS_ALLOWED_ORIGINS = ["http://localhost:5173"]

CORS_ALLOW_CREDENTIALS = True

Replace port 5173 with the port where your React app is being served from.

You also need to tell Django to trust CSRF tokens originating from your React app:

CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"]

Now that everything is setup, we will start building the React app.

If you want to test with Django Rest Framework, please see the repository for an example CRUD app.

React with Vite and Authentication Session

Use Vite to bootstrap a new React project.

When your app loads, you need to issue a GET request to the Allauth session endpoint to retrieve the current session. This will also return the CSRF token cookie, which is required for issuing dangerous requests like POST and DELETE.

Let’s use Tanstack Query for making requests. First, install it:

npm i @tanstack/react-query

Then, create a query function:

async function getSession() {
  const response = await fetch(
    `${import.meta.env.VITE_API_URL}/_allauth/browser/v1/auth/session`,
    {
      credentials: "include",
    },
  );
  const responseData:
    | GetSessionSuccessResponse
    | GetSessionNotAuthenticatedResponse
    | GetSessionInvalidSessionResponse = await response.json();
  if (!response.ok) {
    throw new Error(JSON.stringify(responseData));
  }
  return responseData as GetSessionSuccessResponse;
}

export const sessionsApi = { getSession };
Types available in this file

When the endpoint returns a 200 status code, you want to store the user’s data in a global store somewhere. Let’s use Jotai for that.

Create the following atoms:

import { atom } from "jotai";
import { SessionUser } from "./types";

const isAuthenticatedAtom = atom(false);
const userAtom = atom<SessionUser | null>(null);

export const sessionStore = { isAuthenticatedAtom, userAtom };
Types available in this file

You also want a store for generic stuff like errors and loading state:

import { atom } from "jotai";
import { CurrentAction } from "./types";

const errorsAtom = atom<Error[]>([]);
const isLoadingAtom = atom(false);
const currentActionAtom = atom<CurrentAction>(CurrentAction.Signin);

export const genericStore = {
  errorsAtom,
  isLoadingAtom,
  currentActionAtom,
};
Types available in this file

Next, you’ll use a hook that will fire as soon as your app starts. Call it something like useAuthSession:

import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import { sessionsApi } from "../api/sessions";
import { sessionStore } from "../store/sessions";
import { genericStore } from "../store/generic";
import { CurrentAction } from "../store/generic/types";

export default function useAuthSession() {
  const setIsAuthenticated = useSetAtom(sessionStore.isAuthenticatedAtom);
  const setUser = useSetAtom(sessionStore.userAtom);
  const setIsLoading = useSetAtom(genericStore.isLoadingAtom);
  const setErrors = useSetAtom(genericStore.errorsAtom);
  const setCurrentAction = useSetAtom(genericStore.currentActionAtom);
  const {
    data: session,
    isError,
    isSuccess,
    isPending,
    error,
  } = useQuery({
    queryKey: ["authSession"],
    queryFn: sessionsApi.getSession,
  });

  useEffect(() => {
    if (isSuccess) {
      setIsAuthenticated(true);
      setUser({
        email: session.data.user.email,
        username: session.data.user.username,
        displayName: session.data.user.display,
      });
      setCurrentAction(CurrentAction.Authenticated);
    } else {
      setIsAuthenticated(false);
      setUser(null);
      setCurrentAction(CurrentAction.Signin);
    }

    return () => {
      setIsAuthenticated(false);
      setUser(null);
      setCurrentAction(CurrentAction.Signin);
    };
  }, [isSuccess, session, setIsAuthenticated, setUser, setCurrentAction]);

  useEffect(() => {
    setIsLoading(isPending);

    return () => {
      setIsLoading(false);
    };
  }, [isPending, setIsLoading]);

  useEffect(() => {
    if (isError) {
      setErrors((errors) => [...errors, error]);
    }
  }, [isError, error, setErrors]);
}

This hook will try to retrieve the current session from the API. If the user is authenticated, you save it in the store you created earlier. It also manages the loading state based on whether the query completed or not.

You should fire this hook as soon as the user opens your app, so I recommend putting it in your App component like so:

function App() {
  useAuthSession();
  ...
}

Signing up

To register with your back-end, you will capture the user’s email address and password in a form, then submit it to the signup endpoint.

First, create a mutation that will perform the request:

import { getCSRFToken } from "../../../utils/cookies";

export async function signupMutation(details: {
  email: string;
  password: string;
  username: string;
}) {
  await fetch(
    `${import.meta.env.VITE_API_URL}/_allauth/browser/v1/auth/signup`,
    {
      method: "POST",
      credentials: "include",
      headers: { "X-CSRFTOKEN": getCSRFToken() || "" },
      body: JSON.stringify(details),
    },
  );
}

Notice that we’re using a function called getCSRFToken. It looks up the cookie holding the CSRF token and returns the value. Learn more here and find the full function here.

Next, create the signup form. I’m using Material UI here.

import { Box, Grid, TextField, Button, Container } from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { signupMutation } from "../api/accounts/signup";
import { useSetAtom } from "jotai";
import { genericStore } from "../store/generic";
import { CurrentAction } from "../store/generic/types";
import { useState } from "react";

export default function SignupForm() {
  const queryClient = useQueryClient();
  const setCurrentAction = useSetAtom(genericStore.currentActionAtom);
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { mutate } = useMutation({
    mutationFn: signupMutation,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["authSession"] });
      setCurrentAction(CurrentAction.Signin);
    },
  });
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    mutate({ email, password, username });
  }
  return (
    <Container maxWidth="xs">
      <Box component="form" onSubmit={handleSubmit} sx={{ mt: 3 }}>
        <Grid container spacing={2}>
          <Grid item xs={12}>
            <TextField
              required
              fullWidth
              id="username"
              label="Username"
              name="username"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
            />
          </Grid>
          <Grid item xs={12}>
            <TextField
              required
              fullWidth
              id="email"
              label="Email Address"
              name="email"
              autoComplete="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </Grid>
          <Grid item xs={12}>
            <TextField
              required
              fullWidth
              name="password"
              label="Password"
              type="password"
              id="password"
              autoComplete="new-password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </Grid>
        </Grid>
        <Button
          type="submit"
          fullWidth
          variant="contained"
          sx={{ mt: 3, mb: 2 }}
        >
          Sign Up
        </Button>
      </Box>
    </Container>
  );
}

Don’t worry much about the layout components. The important bits are before the return statement.

First, you run the mutation when the form is submitted. On successful response, you’re invalidating the authSession query. Remember that this query is the one you’re making as soon as the app is loaded. Then, I’m setting a custom state called currentAction, which is specific to how my app is built and not important here.

That’s pretty much it. Users will now be able to sign up to your app using email and password.

You can also setup email verification by following the same process. All you need is a form where the user can enter the key or get it from the query string, then another mutation to POST that information to the verify email endpoint.

Logging in

To log users in, simply create a mutation that will POST to the login endpoint as follows:

import { getCSRFToken } from "../../../utils/cookies";
import { LoginCredentials } from "./types";

class ErrorResponse extends Error {
  status: number;

  constructor(message: string) {
    super(message);
    this.status = 500;
  }
}

export async function loginMutation(credentials: LoginCredentials) {
  const csrfToken = getCSRFToken();
  const response = await fetch(
    `${import.meta.env.VITE_API_URL}/_allauth/browser/v1/auth/login`,
    {
      method: "POST",
      credentials: "include",
      body: JSON.stringify(credentials),
      headers: { "X-CSRFTOKEN": csrfToken || "" },
    },
  );

  const responseData = await response.json();

  if (!response.ok) {
    const error = new ErrorResponse(JSON.stringify(responseData));
    error.status = response.status;
    throw error;
  }

  return responseData;
}

Then create a login form to use that mutation:

import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import {
  Container,
  Box,
  Avatar,
  Typography,
  TextField,
  FormControlLabel,
  Checkbox,
  Button,
  Grid,
  Link,
} from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { loginMutation } from "../../api/accounts/login";
import { useState } from "react";

export default function LoginForm() {
  const queryClient = useQueryClient();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { mutate } = useMutation({
    mutationFn: loginMutation,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["authSession"] });
    },
  });
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    mutate({ email, password });
  }
  return (
    <Container component="section" maxWidth="xs">
      <Box
        sx={{
          marginTop: 8,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
        }}
      >
        <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component="h1" variant="h5">
          Sign in
        </Typography>
        <Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
          <TextField
            margin="normal"
            required
            fullWidth
            id="email"
            label="Email Address"
            name="email"
            autoComplete="email"
            autoFocus
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
          <TextField
            margin="normal"
            required
            fullWidth
            name="password"
            label="Password"
            type="password"
            id="password"
            autoComplete="current-password"
            onChange={(e) => setPassword(e.target.value)}
          />
          <FormControlLabel
            control={<Checkbox value="remember" color="primary" />}
            label="Remember me"
          />
          <Button
            type="submit"
            fullWidth
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            Sign In
          </Button>
          <Grid container>
            <Grid item xs>
              <Link href="#" variant="body2">
                Forgot password?
              </Link>
            </Grid>
            <Grid item>
              <Link href="#" variant="body2">
                {"Don't have an account? Sign Up"}
              </Link>
            </Grid>
          </Grid>
        </Box>
      </Box>
    </Container>
  );
}

Once the mutation succeeds, we’re invalidating thee authSession query from earlier, which will cause the request to fire again. You’ll have have the session information stored in your Jotai store and the user has successfully logged in.

Logout

To logout, you’ll send a DELETE request to the logout endpoint. So, create a mutation:

import { getCSRFToken } from "../../../utils/cookies";

export async function logoutMutation() {
  await fetch(
    `${import.meta.env.VITE_API_URL}/_allauth/browser/v1/auth/session`,
    {
      credentials: "include",
      method: "DELETE",
      headers: { "X-CSRFTOKEN": getCSRFToken() || "" },
    },
  );
}

Then create a button that users can click to logout:

import { Button } from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { logoutMutation } from "../api/accounts/logout";
import { useSetAtom } from "jotai";
import { genericStore } from "../store/generic";

export default function LogoutButton() {
  const queryClient = useQueryClient();
  const setIsLoading = useSetAtom(genericStore.isLoadingAtom);
  const { mutate } = useMutation({
    mutationFn: logoutMutation,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["authSession"] });
      setIsLoading(true);
    },
  });
  return (
    <Button onClick={() => mutate()} variant="contained">
      Logout
    </Button>
  );
}

The mutation fires on click, and the session will become invalidated if the request succeeds, which will essentially log the user out.

Login With Google

For maximum conversion, you should support popular social media authentication systems like Google or Facebook.

Here, I will show you how to add Login with Google.

First, you need to update the ALLOWED_HOSTS variable in your Django settings.

Allauth will use the hosts in this list to verify if it’s allowed to redirect to your front-end app after the user has completed the Google login process.

ALLOWED_HOSTS = ["localhost", "localhost:5173"]

You’ll replace localhost:5173 with the host where your React app is running.

Ensure that your HEADLESS_FRONTEND_URLS contains every important URL as follows:

HEADLESS_FRONTEND_URLS = {
    "account_confirm_email": "http://localhost:5173",
    "account_reset_password_from_key": "http://localhost:5173",
    "account_signup": "http://localhost:5173",
    "socialaccount_login_error": "http://localhost:5173",
}

To login with Google, you must send a form to Allauth’s provider redirect endpoint. It’s important for the request to be a form POST instead of XHR because the endpoint returns a response with 302 status code, which can only be followed by the browser itself.

See this example button:

import { Button } from "@mui/material";
import { getCSRFToken } from "../utils/cookies";

interface LoginWithSocialButtonProps {
  name: string;
  id: string;
}

export default function LoginWithSocialButton({
  name,
  id,
}: LoginWithSocialButtonProps) {
  function handleClick() {
    const form = document.createElement("form");
    form.style.display = "none";
    form.method = "POST";
    form.action = `${import.meta.env.VITE_API_URL}/_allauth/browser/v1/auth/provider/redirect`;
    const data = {
      provider: id,
      callback_url: "http://localhost:5173",
      csrfmiddlewaretoken: getCSRFToken() || "",
      process: "login",
    };

    Object.entries(data).forEach(([k, v]) => {
      const input = document.createElement("input");
      input.name = k;
      input.value = v;
      form.appendChild(input);
    });
    document.body.appendChild(form);
    form.submit();
  }
  return (
    <Button onClick={handleClick} variant="contained">
      Login with {name}
    </Button>
  );
}

This generic button accepts a name and id, which is the Provider ID as configured in Allauth’s settings or in the Django admin.

You can use it as follows:

<LoginWithSocialButton name="Google" id="googleoauth2" />

When you click this button, it will create a form element with the required fields, and POST it to the endpoint. The user will now be redirected to Google for entering their credentials.

Once they’re done, Google will redirect them to the your Django app where Allauth will process the token added to the URL. If will verify this token using the secret key you provided, and if everything’s valid, the user will be logged in. Allauth will now redirect the user to your React app.

Once they’re on your React app, the session will be fetched and the user will appear as logged in.

Conclusion

Django Allauth makes it easier to implement modern authentication in your Django app. Of course, it’s not as simple as something like Clerk, but you get to own your data.

Since authentication isn’t something that changes, I believe it’s worth it to spend the extra time at the beginning to get it setup instead of buying a pre-built solution.

We only scratched the surface here. Allauth provides a complete set of account management utilities, as well as multi-factor authentication. I suggest you take a look at their api specification to learn more. Be sure to check back here again because I plan on writing more posts about the other features that this wonderful library provides.


Email me if you have any questions about this post.

Subscribe to the RSS feed to keep updated.