JWT token as HttpOnly cookie in Django
It is always recommended to store tokens for authentication as HttpOnly cookie instead of storing them in localStorage as a normal cookie which will not be accessible by JavaScript from the frontend. By using an HttpOnly we can avoid XSS attacks on our website. In this tutorial, we will learn how to store a JWT token as a HttpOnly cookie in the browser in Django.
In general, we can retrieve the token from the backend and store them in the localStorage but then our application will be vulnerable to XSS attacks if we are storing tokens in localStorage.
It is a common problem for many users to set the HttpOnly cookie in the browser while using Django for our backend.
In this tutorial, we will try to set HttpOnly for a React application in the browser. The same can be followed for other frameworks like Vue or Angular.
Here, we will be using session token which is generated by Django itself. If you are interested in the JWT token in Django, you can follow this tutorial.
You can find this project on GitHub
Django setup
Let's start a Django project and then an application.
django-admin startproject cookieproject
cd cookieproject
python manage.py startapp cookieapp
Now install Dango rest framework to create APIs, Django cors headers package to enable CORS headers in our Django project and Simple JWT to create JWT tokens.
pip install djangorestframework
pip install django-cors-headers
pip install djangorestframework-simplejwt
Now configure settings.py
file in the project to use these packages.
# cookieproject/settings.py
INSTALLED_APPS = [
# other apps ...
'rest_framework',
'corsheaders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', # new
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
]
CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'cookieapp.authenticate.CustomAuthentication',
),
}
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': True,
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'JTI_CLAIM': 'jti',
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
# custom
'AUTH_COOKIE': 'access_token', # Cookie name. Enables cookies if value is set.
'AUTH_COOKIE_DOMAIN': None, # A string like "example.com", or None for standard domain cookie.
'AUTH_COOKIE_SECURE': False, # Whether the auth cookies should be secure (https:// only).
'AUTH_COOKIE_HTTP_ONLY' : True, # Http only cookie flag.It's not fetch by javascript.
'AUTH_COOKIE_PATH': '/', # The path of the auth cookie.
'AUTH_COOKIE_SAMESITE': 'Lax', # Whether to set the flag restricting cookie leaks on cross-site requests. This can be 'Lax', 'Strict', or None to disable the flag.
}
We will create CustomAuthentication
class later, you may get error at this point of time.
Just because my React application or frontend will be running on localhost and port 3000 that's why I have added it to the list CORS_ALLOWED_ORIGINS
. If you are using some other port, or multiple application then add it to the list.
Then some of the configuration are coming directly from the Simple JWT's official documentation and we have also added some custom settings.
Now run the migrations to create sessions, auth etc. table provided by the Django to perform authentication and then create a superuser which will help us to test the application from the frontend.
python manage.py migrate
python manage.py createsuperuser
Now write the views in our Django application then we will set up the routes to execute the logic through the views.
# cookieapp/views.py
from rest_framework_simplejwt.tokens import RefreshToken
from django.middleware import csrf
from rest_framework.views import APIView
from rest_framework.response import Response
from django.contrib.auth import authenticate
from django.conf import settings
from rest_framework import status
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
class LoginView(APIView):
def post(self, request, format=None):
data = request.data
response = Response()
username = data.get('username', None)
password = data.get('password', None)
user = authenticate(username=username, password=password)
if user is not None:
if user.is_active:
data = get_tokens_for_user(user)
response.set_cookie(
key = settings.SIMPLE_JWT['AUTH_COOKIE'],
value = data["access"],
expires = settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
secure = settings.SIMPLE_JWT['AUTH_COOKIE_SECURE'],
httponly = settings.SIMPLE_JWT['AUTH_COOKIE_HTTP_ONLY'],
samesite = settings.SIMPLE_JWT['AUTH_COOKIE_SAMESITE']
)
csrf.get_token(request)
response.data = {"Success" : "Login successfully","data":data}
return response
else:
return Response({"No active" : "This account is not active!!"}, status=status.HTTP_404_NOT_FOUND)
else:
return Response({"Invalid" : "Invalid username or password!!"}, status=status.HTTP_404_NOT_FOUND)
Get username and password from the user and check if the user is valid then generate the a JWT token using get_tokens_for_user
function provided by Simple JWT package and set it as a HttpOnly cookie send it as a response to the client.
Create a new file authenticate.py
inside the app to create our custom authentication class and define the authenticate
function.
# cookieapp/authenticate.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from django.conf import settings
from rest_framework.authentication import CSRFCheck
from rest_framework import exceptions
def enforce_csrf(request):
check = CSRFCheck()
check.process_request(request)
reason = check.process_view(request, None, (), {})
if reason:
raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)
class CustomAuthentication(JWTAuthentication):
def authenticate(self, request):
header = self.get_header(request)
if header is None:
raw_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None
else:
raw_token = self.get_raw_token(header)
if raw_token is None:
return None
validated_token = self.get_validated_token(raw_token)
enforce_csrf(request)
return self.get_user(validated_token), validated_token
We need to define our own custom Authentication class because we are setting the token as HttpOnly cookie which not accessible by the client that's why the client will not be able to send the token in the header of the request, we will be getting the token in the cookie sent by the client.
So, here we have to check the cookie also, whether the token is available or not.
We are done with the most important part, now set up the URLs.
# cookieproject/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('cookieapp.urls')), # new
]
Then create a new file urls.py
inside cookieapp
application or folder. Then put all the new URLs which will point to our view functions
# cookieapp/urls.py
from django.urls import path
from .views import LoginView
urlpatterns = [
path('login/', LoginView.as_view(), name="login"),
]
We are done with the backend, let's set up the client application.
React Setup
Start a React application first. I will write the minimum possible code here for the demo.
npx create-react-app frontend
cd frontend
npm i axios
Now modify the src/App.js
file and put the following code.
// src/App.js
import axios from "axios";
import { useState } from "react";
import "./App.css";
function App() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const login = async (e) => {
e.preventDefault();
try {
const res = await axios.post(
"http://localhost:8000/login/",
{
username,
password,
},
{ withCredentials: true }
);
console.log(res.data);
} catch (error) {
console.log(error);
}
};
return (
<div className="App">
<form onSubmit={login}>
<div>
<label>Username</label>
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
}}
/>
</div>
<div>
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
}}
/>
</div>
<input type="submit" value="Login" />
</form>
</div>
);
}
export default App;
Open your frontend application and check initially the cookie field will be empty. Now input your credentials which we have created earlier to test the application.
After taking the username and password from the user through our form an API is called to log in the user when the form will be submitted in which we are passing username and password. Make sure to add {withCredentials: true}
with the API call otherwise it will not work.
Here, you will notice that the JWT token is stored as HttpOnly only cookie.
NOTE:
- If you are running the project locally in development then make sure use either http://localhost or http://127.0.0.1 on both frontend and backend, while calling the API and browsing the application in browser i.e., do not user separate domain name.
- To set a cookie as HttpOnly it's necessary for your client and server to be on the same domain otherwise it will not set.
- In production you can use either proxy URL or host backend on same domain using different subdomain like api.example.com