HTTPS and Cookies
Introduction
In the previous module we locked down our server configuration and eliminated exposed secrets. But there is still one piece of the authentication flow that deserves attention: the token.
Right now, after a user logs in, our API returns the token in the response body. The React frontend receives it, stores it in browser memory or localStorage, and manually attaches it as an Authorization header on every subsequent request. This works — but it has a significant weakness. Any JavaScript running on the page can read localStorage. If an attacker manages to inject a script into your application through an XSS vulnerability, that script can read the token and send it anywhere in the world.
HTTP cookies — specifically HttpOnly cookies — solve this problem. The token lives in a cookie that JavaScript cannot touch. The browser manages it, attaches it automatically to every matching request, and it never appears in JavaScript-accessible memory.
Lesson
What are HTTP Cookies
An HTTP cookie is a small piece of data the server sends to the browser in a response header. The browser stores it and automatically includes it in the header of every future request to the same domain — no manual attachment required.
Cookies are structured as key-value pairs with optional attributes that control their behavior:
| Attribute | Purpose |
|---|---|
HttpOnly |
JavaScript cannot read this cookie — it is only accessible by the browser's networking layer |
Secure |
The cookie is only sent over HTTPS connections, never over plain HTTP |
SameSite |
Controls whether the cookie is sent with cross-site requests — Lax allows it for top-level navigations but blocks it for embedded requests |
Expires |
When the cookie should be deleted from the browser |
The HttpOnly attribute is the critical one for security. A cookie marked HttpOnly is invisible to document.cookie and all JavaScript APIs. Even if an attacker injects a script into your page, that script cannot read the token.
Auth Tokens vs Auth Cookies
Let's compare how the current token flow and the cookie flow differ:
| Auth Token (current) | Auth Cookie | |
|---|---|---|
| Where is the token stored? | localStorage or JavaScript memory |
Browser cookie storage |
| Can JavaScript read it? | Yes | No (HttpOnly) |
| Is it sent automatically? | No — must be manually attached as a header | Yes — browser attaches it automatically |
| Vulnerable to XSS? | Yes — a script can steal the token | No — HttpOnly makes it unreachable |
| Requires HTTPS? | No | Yes (with Secure flag) |
The core tradeoff is control vs. security. Tokens in localStorage give you full control — you can read, write, and delete them from JavaScript. But that accessibility is precisely what makes them a target. HttpOnly cookies give that control to the browser instead, and attackers can no longer steal what they cannot read.
How do Cookies Work
Here is the exact sequence when authentication switches to cookies:
- The user submits their email and password to
/api/v1/users/login/. - Django authenticates the credentials and retrieves or creates a token for the user.
- Instead of returning the token in the response body, Django calls
response.set_cookie(...), which adds aSet-Cookieheader to the response. - The browser reads the
Set-Cookieheader and stores the cookie locally, associating it with the domain. - On every subsequent request to the same domain, the browser automatically includes the cookie in a
Cookieheader — with no JavaScript involvement required. - Django's authentication middleware reads the cookie, extracts the token value, and uses it to identify the user.
The browser handles steps 4 and 5 entirely. The React frontend no longer needs to know the token exists.
Applying Cookies with Django
Updating Server Configurations
Add the following settings to settings.py. These settings control how Django's session cookies behave — and they need to align with our new cookie-based authentication strategy:
SESSION_COOKIE_SECURE = eval(os.environ.get('SESSION_COOKIE_SECURE', 'False'))
SESSION_COOKIE_HTTPONLY = eval(os.environ.get('SESSION_COOKIE_HTTPONLY', 'False'))
CSRF_COOKIE_SECURE = eval(os.environ.get('CSRF_COOKIE_SECURE', 'False'))
Add the corresponding variables to .env:
SESSION_COOKIE_SECURE=True
SESSION_COOKIE_HTTPONLY=True
CSRF_COOKIE_SECURE=True
And to .env.example:
SESSION_COOKIE_SECURE=True
SESSION_COOKIE_HTTPONLY=True
CSRF_COOKIE_SECURE=True
SESSION_COOKIE_SECURE ensures session cookies are only transmitted over HTTPS — the browser will refuse to send them over plain HTTP. SESSION_COOKIE_HTTPONLY makes session cookies inaccessible to JavaScript. CSRF_COOKIE_SECURE applies the same HTTPS requirement to Django's CSRF token cookie.
The eval(...) call converts the string 'True' or 'False' that os.environ.get() returns into an actual Python boolean. Environment variables are always strings — eval handles the conversion.
The defaults are 'False' so that local development still works without HTTPS. On the production server, the .env file sets them all to True.
Updating Create and Log In User Views
The LogIn and CreateUser views currently return the token in the response body. We're going to change them to set the token as an HttpOnly cookie instead.
In user_app/views.py:
from django.contrib.auth import authenticate
from .models import AppUser
from rest_framework.authtoken.models import Token
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework import status as s
from datetime import datetime, timedelta
from task_proj.utils import handle_exceptions
class CreateUser(APIView):
authentication_classes = []
permission_classes = []
@handle_exceptions
def post(self, request):
data = request.data.copy()
data['username'] = data.get('email')
new_user = AppUser.objects.create_user(**data)
new_user.full_clean()
new_user.save()
token = Token.objects.create(user=new_user)
life_time = datetime.now() + timedelta(days=7)
format_life_time = life_time.strftime("%a, %d %b %Y %H:%M:%S GMT")
response = Response({"email": new_user.email}, status=s.HTTP_201_CREATED)
response.set_cookie(
key="token",
value=token.key,
httponly=True,
secure=True,
samesite="Lax",
expires=format_life_time
)
return response
class LogIn(APIView):
authentication_classes = []
permission_classes = []
@handle_exceptions
def post(self, request):
data = request.data.copy()
data['username'] = data.get('email')
user = authenticate(username=data.get('username'), password=data.get('password'))
if user:
token, _ = Token.objects.get_or_create(user=user)
life_time = datetime.now() + timedelta(days=7)
format_life_time = life_time.strftime("%a, %d %b %Y %H:%M:%S GMT")
response = Response({"email": user.email})
response.set_cookie(
key="token",
value=token.key,
httponly=True,
secure=True,
samesite="Lax",
expires=format_life_time
)
return response
return Response("No user matching credentials", status=s.HTTP_404_NOT_FOUND)
The key change is that the token is no longer in the response body — it is set on the response as a cookie via response.set_cookie(...). The client receives the user's email to confirm the login succeeded, but never sees the raw token value.
Breaking down the set_cookie arguments:
| Argument | Value | Why |
|---|---|---|
key |
"token" |
The cookie name — must match what CookieAuthentication looks for |
value |
token.key |
The actual DRF auth token |
httponly |
True |
JavaScript cannot read this cookie |
secure |
True |
Only sent over HTTPS |
samesite |
"Lax" |
Sent on same-site requests and top-level cross-site navigations, but blocked on embedded cross-site requests |
expires |
7 days from now | When the browser should delete the cookie |
The expiry is formatted as an RFC 7231 date string because that is the format the Set-Cookie header requires.
Creating our own Authentication Class
UserView currently uses DRF's built-in TokenAuthentication. That class looks for an Authorization: Token <key> header on incoming requests. Now that the token arrives as a cookie instead of a header, TokenAuthentication will find nothing and reject every request.
We need to teach Django how to find the token in a cookie. Create a new class in user_app/views.py that inherits from TokenAuthentication and overrides where it looks for the token:
from rest_framework.authentication import TokenAuthentication
class CookieAuthentication(TokenAuthentication):
def get_auth_token_from_cookie(self, request):
return request.COOKIES.get('token')
def authenticate(self, request):
auth_token = self.get_auth_token_from_cookie(request)
if not auth_token:
return None
return self.authenticate_credentials(auth_token)
TokenAuthentication already knows how to validate a token string against the database and return the associated user — that logic lives in authenticate_credentials. All we are doing is changing where the token string comes from: instead of the Authorization header, it comes from request.COOKIES.get('token').
When authenticate returns None, DRF moves on to the next authentication class in the list. This is important for backward compatibility — we can support both header-based and cookie-based authentication simultaneously.
Updating UserViews
Update UserView to include both authentication classes. DRF will try them in order and use whichever one successfully identifies a user:
class UserView(APIView):
authentication_classes = [CookieAuthentication, TokenAuthentication]
permission_classes = [IsAuthenticated]
CookieAuthentication is listed first. If the request has a token cookie, it is used. If not — for example, during testing with an API client that sends the Authorization header — TokenAuthentication handles it. No existing tooling breaks.
Update LogOut to also clear the cookie on logout so the browser stops sending it:
class LogOut(UserView):
@handle_exceptions
def post(self, request):
user = request.user
user.auth_token.delete()
response = Response(f"{user.email} has been logged out")
response.delete_cookie("token")
return response
response.delete_cookie("token") instructs the browser to remove the cookie immediately by setting its expiry date to the past.
Applying Cookies to React
With the server setting the token as a cookie, the React frontend no longer needs to manage tokens at all. It does not need to store them, read them, or attach them to requests — the browser handles all of that automatically.
Two things need to change on the frontend.
First, the Axios instance must include withCredentials: true:
const api = axios.create({
baseURL: "https://yourdomain.com/api/v1/",
withCredentials: true,
});
withCredentials: true tells Axios to include cookies when making cross-origin requests. Without this flag, the browser strips cookies from requests that cross domain boundaries — even if the cookie exists and the server is ready to read it. This is the browser's default security behavior. Setting withCredentials opts into the authenticated cross-origin pattern explicitly.
Second, remove all manual token handling. Anywhere the frontend previously:
- Read a token from
localStorageafter login - Stored a token to
localStorage - Attached
Authorization: Token <key>to request headers - Deleted the token from
localStorageon logout
...none of that is needed anymore. The browser handles it. A login request succeeds, the browser stores the cookie, and from that point forward every request from that browser automatically carries the authentication credential.
The login response now returns only { email } — and that is all the frontend needs to update its state and redirect the user.
Conclusion
Switching from localStorage token storage to HttpOnly cookies is one of the highest-impact security improvements available to a full-stack application, and the implementation is straightforward once the pieces are understood:
- HttpOnly cookies store the authentication token in a part of the browser that JavaScript cannot reach, eliminating the most common token theft vector.
- The
Secureflag ensures the cookie never travels over unencrypted HTTP — which is why HTTPS had to come first. CookieAuthenticationbridges DRF's token validation logic with the new cookie-based delivery mechanism without rewriting authentication from scratch.withCredentials: trueopts Axios into the browser's cross-origin cookie behavior so the token travels with every request automatically.- Session and CSRF cookie settings in
settings.pybring Django's own internal cookies in line with the same security posture.
The frontend got simpler: no token storage, no header management, no logout cleanup logic. The server got more secure: the credential is invisible to JavaScript and only transmitted over encrypted connections. Both outcomes are the result of using the browser's built-in cookie infrastructure correctly rather than reinventing it in JavaScript.