Cookie-based authentication with SPA and Django
Disclaimer: I do not work in security, and this article does not make any security recommendations. For advice specific to your situation, please consult a security professional.
A question I see asked a lot is how to implement authentication between an SPA (e.g. React, Vue, etc.) and a Django API.
The two methods that I frequently see in the wild are either token-based authentication, or cookie-based authentication. The terminology is somewhat confusing as tokens are used for both mechanisms, but “token-based authentication” usually refers to manually inserting a token in a header in an AJAX request, whereas “cookie-based authentication” refers to using cookies to send this automatically.
Sometimes you will see some security-focused articles recommending against using token-based authentication, and advocating cookies instead. Unfortunately, there isn’t a lot of up-to-date information on how to actually do this in Django, so I’m hoping that this article will help.
First, I will explain why many articles recommend using cookies over local storage, and also describe some things that most of those articles don’t tell you. Then I will show you how to do cookie-based auth with Django.
What’s the problem with token-based auth?
When people refer to “token-based auth” they mean attaching a token to a header (frequently the Authorization
header) manually in an AJAX request. Here’s an example using the axios
library:
const token = "someAuthenticationToken";
axios.get("/some_endpoint/", {
headers: { Authorization: `Bearer: ${token}` }
});
The main objection to this approach is that you will need to store the token somewhere that is accessible using JavaScript. This normally means the browser’s localStorage
.
Since you can get the token using JavaScript, if your site is vulnerable to XSS (cross-site scripting), someone can steal that token.
That’s pretty much the main gist of the argument.
Here is an article from Auth0 that recommends against storing tokens in localStorage
for the reason I just described.
If you don’t know what XSS is, read the following section, otherwise feel free to skip it.
What’s XSS?
XSS is an exploit where an attacker manages to get malicious JavaScript to run on your browser.
This can happen in a number of ways, and is quite difficult to detect and very easy to miss.
A simple example is if you allow users to submit some text via a form, and you render that text as HTML to other users. Think of something like a message board that allows you to insert HTML in your posts.
Since <script>
tags are valid HTML, an attacker could enter in some JavaScript that grabs your localStorage
and sends it to some remote server. I’ll leave it to you to find some examples online on how this is done.
How does using cookies help?
The argument normally goes like this:
You can enable a setting called HttpOnly
on session cookies. It’s a confusing name, but HttpOnly
means your browser will only send the cookie automatically only on HTTP requests, and it is not accessible via JavaScript. So in the event that XSS occurs, an attacker would not be able to steal your token directly.
Cookies don’t protect you against XSS
Unfortunately, many articles stop there, and in my opinion readers may get the wrong impression that if you use cookies you’re safe if XSS happens.
You are NOT protected against XSS if you use cookies.
If I were an attacker, and I can run malicious JavaScript on your browser while you’re logged in, then I can pretty much do anything you can do on your browser. Since the cookies will be sent automatically with each request, I don’t even have to know your token to make authenticated requests. This is equivalent to finding someone’s laptop with an open tab where the owner forgot to log out.
In fact, tokens can expire (and they usually do have a short expiry time) or be invalidated, so stealing a token that may be expired by the time I gets my hands on it may not be very useful. It’s more desirable to make your browser do things for me, while you’re still logged in.
For example, I can make an authenticated request on your behalf to change your email address to mine. I can then initiate a password reset, which will send the password reset link to my email address where I can change your password.
Or, if I only cared about your data, I could just do a GET request to the APIs and send the response data off somewhere.
Or, I could run a malicious script to manipulate the DOM and render a form that asks you to re-enter your password, and have that form send the password to me. If you re-use your passwords in other sites, then now all of your accounts online are vulnerable.
Does that mean it doesn’t really matter where I put the token?
That is a decision you will need to make yourself by considering all factors, including risk, as well as other security measures you put in place, e.g. CAPTCHA, OTP code to phone before certain changes, password re-entry, etc.
If you have other protections in place, you may determine that the choice does not make a huge difference.
However, I will not make a security recommendation, because I am not a security professional.
For a more nuanced discussion about this, I can point you to an article about this called Web Storage: the lesser evil for session tokens from an actual security researcher.
I can say, however – hopefully without much controversy – that if a cookie has the HttpOnly
and Secure
settings turned on, then storing the token in the cookie is probably not more vulnerable than storing it in localStorage
, assuming the appropriate CSRF protections are put in place. Using a cookie gives you additional protection against the token being accessed directly by JavaScript.
For that reason, I would normally prefer using a cookie, with the appropriate CSRF protections.
Read on to find out how to do this in various circumstances.
Do I need to serve the SPA using Django templates in order to use cookies?
No, you do not.
But if that’s your deployment setup, then you don’t have to do anything else. Django handles this out of the box.
How do I use cookies if my SPA and Django are deployed separately?
Your browser doesn’t know or care how your SPA and backend are deployed. It only knows the domain of where it’s making requests to.
It is possible to serve your Django and your SPA from the same domain, and this is the setup I would recommend for most applications. One way is to use nginx or reverse proxy to proxy requests to the right place based on the path of the request.
If you use nginx, you would be doing something like this (just for illustration, you should be using SSL in your own deployment):
server {
listen 80;
server_domain example.com
location /api/ {
include proxy_params
proxy_pass http://unix:/tmp/gunicorn.sock;
}
location / {
root /path/to/spa;
}
}
If you use Netlify to serve your SPA, you could also write some redirect/proxy rules.
This might be tricky if you happen to have path conflicts, but you can get over that by namespacing the URLs for the Django API, e.g. by having all requests to paths starting with /api/
go to Django.
You’ll also need to set your CSRF cookie somehow, or your request to login will fail. Since your page is being rendered by your SPA rather than Django, your CSRF cookie (or any other cookies that Django cares about) wouldn’t be set in the beginning. To get around this, you need to make an initial request to an endpoint which will set the cookie.
If you set up the proxy correctly, then you can basically have some backend code that looks like this (for illustration only, this isn’t production-ready code):
import json
from django.contrib.auth import authenticate, login
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import JsonResponse
@ensure_csrf_cookie
def set_csrf_token(request):
"""
This will be `/api/set-csrf-cookie/` on `urls.py`
"""
return JsonResponse({"details": "CSRF cookie set"})
@require_POST
def login_view(request):
"""
This will be `/api/login/` on `urls.py`
"""
data = json.loads(request.body)
username = data.get('username')
password = data.get('password')
if username is None or password is None:
return JsonResponse({
"errors": {
"__all__": "Please enter both username and password"
}
}, status=400)
user = authenticate(username=username, password=password)
if user is not None:
login(request, user)
return JsonResponse({"detail": "Success"})
return JsonResponse(
{"detail": "Invalid credentials"},
status=400,
)
Then from your frontend, you can write some functions like this:
const loginRequest = async (username, password) => {
try {
await axios.post(
'/api/login/',
{ username, password },
{ headers: { X-CSRFToken: getCsrfToken(), Content-Type: "application/json" } }
);
} catch (error) {
// handle error here
}
}
const setCsrf = async () => {
await axios.get('/api/set-csrf-cookie/');
}
You’re pretty much done at this point.
What if my SPA and Django are on different domains?
You’ll need to relax quite a few security settings, and it’s quite complicated so I’d strongly advise not doing this.
In this case, I’d lean towards using a token in the request header. It’s far less complicated, and not as easy to screw up.
But if you can’t avoid it… read on.
What’s being described here is a setup I see sometimes (actually pretty often) where the SPA and the API are on different domains.
Often they are subdomains, e.g. api.example.com
for the API and app.example.com
for the SPA. But sometimes they are also on completely different domains.
There are a number of things you need to do to get cross-domain requests to work with cookies.
Use the correct CORS settings
Browsers have a security feature called the same-origin policy that blocks cross-domain requests by default. But you are able to relax this security feature by enabling something called CORS (Cross-Origin Resource Sharing).
To enable CORS, you will need to configure your server to return a number of headers that suit your needs.
To enable cross-domain requests at all, the server will need to return this header:
Access-Control-Allow-Origin: https://app.example.com
It is extremely vital that you whitelist only the domains you trust, otherwise you leave yourself vulnerable.
For cookies to be sent cross-domain, you will also need to enable this header:
Access-Control-Allow-Credentials: true
Many Django applications use a library called django-cors-headers. If that’s what you’re using, please refer to the documentation to enable the two headers.
You could also have nginx return those headers, which is probably a little better.
Turn off the SameSite
setting
If you use cookies, you will need to care about something called CSRF (cross-site request forgery). Most likely you already have had experience with this by attaching {% csrf_token %}
to your forms, if you use Django.
A CSRF attack is when someone manages to get you to make a POST request from a different origin, e.g. by making you fill in a form in a different domain that targets your app’s domain, or an AJAX request. Since cookies are sent automatically, this means you will end up making an authenticated request, which you didn’t intend.
Kind of like what you’re trying to do with this deployment setup, except maliciously.
A very recent addition to cookies is a setting called SameSite
, with the purpose of preventing some CSRF attacks. As its name implies, it’s a cookie that won’t be sent in cross-domain requests.
Starting from Django 2.1, session cookies and CSRF cookies have this setting turned on by default.
Prior to version 2.1, Django relied on a CSRF token to protect against CSRF attacks. The way this works is that in POST requests, the browser needs to send a CSRF token through either one of two methods – either together with a form submission (that’s why you have to put {% csrf_token %}
in your forms), or in a header (X-CSRFToken
by default) for Ajax requests (you grab the token from a non-HttpOnly cookie).
Actually, Django still does this as some old browsers may not support SameSite
cookies yet.
In the case where the SPA and the Django API are on different domains, you cannot have the SameSite
setting enabled for your session cookies and CSRF cookies. So you’ll need to add these two settings to your settings.py
file:
SESSION_COOKIE_SAMESITE = None
CSRF_COOKIE_SAMESITE = None
EDIT (Aug 2020): Starting Django 3.1 you’ll need to use the string ‘None’, see: https://docs.djangoproject.com/en/3.1/ref/settings/#csrf-cookie-samesite
You’ll also need to explicitly tell Django to trust CSRF tokens sent from your SPA’s domain using the CSRF_TRUSTED_ORIGINS
setting:
CSRF_TRUSTED_ORIGINS = ["app.example.com"]
It’s always important that you validate CSRF tokens when using cookies, and if you use these configurations it is even more crucial, as you can no longer rely on the SameSite
behaviour of cookies.
If you use Django REST Framework, APIView
and ViewSet
will use the csrf_exempt
decorator, meaning CSRF protections are being bypassed by default (because you might not be using cookies).
You will need to configure your viewsets to use the SessionAuthentication
backend, which will enable CSRF protections.
Use withCredentials
when making AJAX requests
By default, cookies are not sent (or set) for cross-domain requests (regardless of CORS settings).
You’ll need to explicitly tell your request to send cookies via the withCredentials
property, e.g:
axios
.get("https://api.example.com/some_resource/", { withCredentials: true })
.then(console.log)
.catch(console.log);
Summary
In this article, I discussed why cookies, specifically HttpOnly cookies, are often recommended for session tokens over saving tokens in local storage.
I explained that using cookies doesn’t mean that your application is protected against XSS. However, it does mean that someone can’t steal your session token directly.
I then discussed three deployment options, and how cookies work in each one:
- SPA served via Django templates, where no action is needed
- SPA and Django API on the same domain, which requires a small amount of code in the frontend to set a CSRF cookie as this is no longer done automatically.
- SPA and Django API on different domains, which requires relaxing a number of security settings (CORS, SameSite).
I will hopefully update this post with an actual example repo once I have the time to do it.
If you have any questions or corrections about this post, please feel free to send me an email. I’d love to hear about what you’re doing.