Robust Full-Stack Authentication with Django Allauth, React, and React Router
Learn how to integrate django-allauth with React for a powerful and easy authentication system.
Published: 04 Sep, 2024
Updated: 08 Jan, 2025
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.
This tutorial is lengthy. Clone this git repository to see a complete example including Tanstack Query and React Router.
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 this tutorial
In this tutorial, we’ll be using Django’s default session backend. You won’t be able to use it in situations where you want to provide third party websites 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 implement a custom token strategy for Allauth. This isn’t something I’ll cover in this tutorial but I might write about it in a future one.
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.
Make sure that you don’t use the CORS_ALLOW_ALL_ORIGINS option because it will prevent cookies from being shared with your front-end react app. Instead, set all the hosts explicitly in the CORS_ALLOWED_ORIGINS list.
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();
const okCodes = [200, 401, 410];
if (okCodes.indexOf(response.status) === -1) {
throw new Error(JSON.stringify(responseData));
}
return { isAuthenticated: responseData.meta.is_authenticated };
}
export const sessionsApi = { getSession };
Types available in this file
It’s important to set credentials to include in fetch options for every request because it will cause fetch to automatically include cookies in the request. Since the session ID is stored in a cookie, you need this option to enable authenticated requests.
You’ll typically setup a generic query function or class to take care of setting the proper options in a real production environment.
According to the session endpoint’s documentation, it will return status codes 200, 401, and 410 depending on the situation. We will consider those codes as a successful request. For any other code, you will throw an exception so that Tanstack Query can retry the request. We will return the authentication status inside an object.
Create a custom hook to make this query reusable:
export function useAuthSessionQuery() {
return useQuery({
queryKey: ["authSession"],
queryFn: sessionsApi.getSession,
});
}
React Router
Your routes should be setup as follows:
<BrowserRouter>
<Routes>
<Route path="/" element={<Initializer />}>
<Route index element={<Navigate to="/app" />} />
<Route path="auth" element={<PublicOnlyRoute />}>
<Route index element={<Navigate to="/auth/login" />} />
<Route path="login" element={<LoginForm />} />
<Route path="signup" element={<SignupForm />} />
</Route>
<Route path="app" element={<PrivateRoute />}>
<Route index element={<CrudBody />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
When the app loads, you will render a component called Initializer. Here it is:
function Initializer() {
const { isLoading } = useAuthSessionQuery();
if (isLoading) {
return <p>Loading...</p>;
}
return <Outlet />;
}
It will fire a request to fetch the current session while showing a loading screen until the query returns, and render the actual route once it’s done.
The index route will redirect to the main app page, where we will first render the PrivateRoute component:
export default function PrivateRoute() {
const { data } = useAuthSessionQuery();
if (data?.isAuthenticated) {
return <Outlet />;
}
return <Navigate to="/auth/login" />;
}
You will use the session data again, but this time, you’ll check if the user is authenticated. Unauthenticated users will be allowed to view the page, whereas unauthenticated ones will be redirected to the login page.
Notice that we first render a component called PublicOnlyRoute before rendering the authentication routes. Here’s what it looks like:
import { Navigate, Outlet } from "react-router";
import { useAuthSessionQuery } from "../api/sessions/hooks";
export default function PublicOnlyRoute() {
const { data } = useAuthSessionQuery();
if (data?.isAuthenticated) {
return <Navigate to="/app" />;
}
return <Outlet />;
}
This component does the opposite of the PrivateRoute, where it will redirect authenticated requests to the main app route instead of rendering the authentication pages.
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 AppRegistrationIcon from "@mui/icons-material/AppRegistration";
import {
Avatar,
Box,
Button,
Container,
Grid,
Link,
TextField,
Typography,
} from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link as RouterLink } from "react-router";
import { signupMutation } from "../api/accounts/signup";
export default function SignupForm() {
const queryClient = useQueryClient();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { mutate } = useMutation({
mutationFn: signupMutation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["authSession"] });
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
mutate({ email, password, username });
}
return (
<Container component="section" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<AppRegistrationIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<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>
<Grid container>
<Grid item>
<Link component={RouterLink} to="/auth/login" variant="body2">
{"Already have an account?"}
</Link>
</Grid>
</Grid>
</Box>
</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. Once the query is invalidated, Tanstack Query will fire it again to fetch the new session data from the backend, which should now show the user as authenticated depending on how you setup allauth.
That’s pretty much it. Users will now be able to sign up to your app using email and password.
I don’t cover email verification upon signup here, but it’s trivial to implement by simply POSTing the key sent to the user’s email 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 {
Avatar,
Box,
Button,
Checkbox,
Container,
FormControlLabel,
Grid,
Link,
TextField,
Typography,
} from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link as RouterLink } from "react-router";
import { loginMutation } from "../../api/accounts/login";
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 component={RouterLink} to="/auth/signup" variant="body2">
{"Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
}
Once again, we’re invalidating thee authSession query upon successful requests, which will cause the authSession request to fire again. Once it succesfully returns, the user will be redirected to the /app route, as we set in the PublicOnlyRoute component.
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";
export default function LogoutButton() {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: logoutMutation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["authSession"] });
},
});
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 and redirect then to the login page as we specified in the PrivateRoute component.
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 is best handled 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.