From abaaa395ccf9111d58c455a7bba50488ecd42127 Mon Sep 17 00:00:00 2001 From: anirudhk06 Date: Mon, 5 Jan 2026 16:15:14 +0530 Subject: [PATCH 1/4] feat(auth): add cookie-based JWT authentication --- rest_framework_simplejwt/authentication.py | 21 ++++++++++++++++++++- rest_framework_simplejwt/settings.py | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index fbbe8f708..ffe7a0602 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -1,5 +1,6 @@ -from typing import Optional, TypeVar +from typing import Any, Optional, TypeVar +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser from django.utils.translation import gettext_lazy as _ @@ -176,6 +177,24 @@ def get_user(self, validated_token: Token) -> AuthUser: JWTTokenUserAuthentication = JWTStatelessUserAuthentication +class JWTCookieAuthentication(JWTAuthentication): + cookie_name: str = api_settings.AUTH_COOKIE + + def authenticate(self, request: Request) -> tuple[AuthUser, Token] | None: + raw_token: str | None = self.get_cookie(request).get(self.cookie_name) + + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + user = self.get_user(validated_token) + + return user, validated_token + + def get_cookie(self, request: Request) -> dict[str, str]: + return request._request.COOKIES + + def default_user_authentication_rule(user: AuthUser | None) -> bool: # Prior to Django 1.10, inactive users could be authenticated with the # default `ModelBackend`. As of Django 1.10, the `ModelBackend` diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index 5c4d9fc4a..f4f9555a8 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -47,6 +47,7 @@ "CHECK_REVOKE_TOKEN": False, "REVOKE_TOKEN_CLAIM": "hash_password", "CHECK_USER_IS_ACTIVE": True, + "AUTH_COOKIE": "access" } IMPORT_STRINGS = ( From 5b091ed3bd6c4c0d5a123431e398adba8c070ba2 Mon Sep 17 00:00:00 2001 From: anirudhk06 Date: Mon, 5 Jan 2026 16:55:58 +0530 Subject: [PATCH 2/4] cookie authentication testing view created --- tests/test_authentication.py | 42 ++++++++++++++++++++++++++++++++++++ tests/urls.py | 1 + tests/views.py | 11 ++++++++++ 3 files changed, 54 insertions(+) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index b17a12caa..fae75b667 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -7,6 +7,7 @@ from rest_framework_simplejwt import authentication from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken +from rest_framework.request import Request from rest_framework_simplejwt.models import TokenUser from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, SlidingToken @@ -277,3 +278,44 @@ def username(self): # Restore default TokenUser for future tests api_settings.TOKEN_USER_CLASS = temp + + +class TestJWTCookieAuthentication(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.backend = authentication.JWTCookieAuthentication() + + self.user = User.objects.create_user( + username="anirudhk", password="password123", is_active=True + ) + + self.access_token = str(AccessToken.for_user(self.user)) + + def test_cookie_authentication(self) -> None: + django_request = self.factory.get("/protected-cookie/") + django_request.COOKIES[api_settings.AUTH_COOKIE] = self.access_token + + result = self.backend.authenticate(Request(django_request)) + + self.assertIsNotNone(result) + + user, token = result + + self.assertEqual(user, self.user) + self.assertIsInstance(token, AccessToken) + + def test_authenticate_without_token(self) -> None: + dj_request = self.factory.get("/cookie-protected/") + request = Request(dj_request) + + result = self.backend.authenticate(request) + + self.assertIsNone(result) + + def test_authenticate_with_invalid_cookie(self) -> None: + dj_request = self.factory.get("/cookie-protected/") + dj_request.COOKIES[api_settings.AUTH_COOKIE] = "invalid_token_value" + + request = Request(dj_request) + with self.assertRaises(AuthenticationFailed): + self.backend.authenticate(request) diff --git a/tests/urls.py b/tests/urls.py index dc8edcac2..9c86aaff6 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -18,4 +18,5 @@ re_path(r"^token/verify/$", jwt_views.token_verify, name="token_verify"), re_path(r"^token/blacklist/$", jwt_views.token_blacklist, name="token_blacklist"), re_path(r"^test-view/$", views.test_view, name="test_view"), + re_path(r"^cookie-protected/$", views.cookie_protected_view, name="cookie_protected_view"), ] diff --git a/tests/views.py b/tests/views.py index ee0e81409..1668d4df7 100644 --- a/tests/views.py +++ b/tests/views.py @@ -14,3 +14,14 @@ def get(self, request): test_view = TestView.as_view() + + +class CookieProtectedView(APIView): + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (authentication.JWTCookieAuthentication,) + + def get(self, request): + return Response({"foo": "bar"}) + + +cookie_protected_view = CookieProtectedView.as_view() From 258cf6db0073589e99e7f4317b7cd70c403d3332 Mon Sep 17 00:00:00 2001 From: anirudhk06 Date: Mon, 5 Jan 2026 17:30:07 +0530 Subject: [PATCH 3/4] git commit -m "docs(auth): document JWTCookieAuthentication backend" --- docs/cookie_authentication.rst | 134 +++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 135 insertions(+) create mode 100644 docs/cookie_authentication.rst diff --git a/docs/cookie_authentication.rst b/docs/cookie_authentication.rst new file mode 100644 index 000000000..509513c0d --- /dev/null +++ b/docs/cookie_authentication.rst @@ -0,0 +1,134 @@ +.. _cookie_authentication: + +Cookie-based Authentication +=============================== + +Overview +-------- + +``JWTCookieAuthentication`` is an optional authentication backend that +authenticates JSON Web Tokens (JWTs) **exclusively from HttpOnly cookies**. + +Unlike ``JWTAuthentication``, this backend **does not read tokens from the +Authorization header**. Instead, it expects JWTs to already be present in +cookies attached to the incoming request. + +This authentication method is intended for browser-based applications +where storing JWTs in HttpOnly cookies is preferred over exposing tokens +to JavaScript. + +--- + +How it works +------------ + +The ``JWTCookieAuthentication`` backend behaves as follows: + +* Reads the JWT from a configured cookie (default: ``access``) +* If the cookie is missing, authentication is skipped +* If the cookie contains an invalid or expired token, authentication fails +* The Authorization header is intentionally ignored + +This backend only handles **authentication**. It does not issue tokens +or set cookies. + +--- + +Enabling cookie authentication +------------------------------ + +To enable cookie-based authentication, configure Django REST Framework +to use ``JWTCookieAuthentication``: + +.. code-block:: python + + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTCookieAuthentication", + ), + } + +Configure the cookie name using ``SIMPLE_JWT`` settings: + +.. code-block:: python + + SIMPLE_JWT = { + "AUTH_COOKIE": "access", + } + +--- + +Token issuance and login +------------------------ + +``JWTCookieAuthentication`` **does not create or set cookies**. + +The default SimpleJWT token views (``TokenObtainPairView`` and +``TokenRefreshView``) return tokens in the response body and **do not store +tokens in cookies**. + +When using cookie-based authentication, JWTs **must already be present in +cookies**. This typically requires using **cookie-aware login and refresh +views** that store tokens in HttpOnly cookies. + +Example of setting a cookie after successful authentication: + +.. code-block:: python + + response.set_cookie( + "access", + access_token, + max_age=3600, + path="/", + secure=True, + httponly=True, + samesite="Lax", + ) + +The responsibility for issuing tokens and setting cookies lies outside +of this authentication backend. + +--- + +Refresh tokens +-------------- + +If refresh tokens are used with cookie-based authentication, they should +also be stored in HttpOnly cookies and handled by cookie-aware refresh +views. + +The default ``TokenRefreshView`` expects refresh tokens in the request +body and does not read cookies. + +--- + +Security considerations +----------------------- + +When using cookie-based JWT authentication, the following security +requirements apply: + +* Cookies **must** be marked ``HttpOnly`` to prevent access from JavaScript +* Cookies **should** be marked ``Secure`` when used over HTTPS +* CSRF protection **must be enabled** for unsafe HTTP methods +* This backend is recommended only for browser-based clients + +Failure to enforce CSRF protection when using cookies may expose the +application to cross-site request forgery attacks. + +--- + +When to use this backend +----------------------- + +Use ``JWTCookieAuthentication`` if: + +* You are building a browser-based application +* You want to avoid storing JWTs in JavaScript-accessible storage +* You prefer HttpOnly cookie-based authentication + +Do **not** use this backend if: + +* You rely on Authorization headers for authentication +* You are building APIs intended for third-party clients +* You require stateless, header-only authentication diff --git a/docs/index.rst b/docs/index.rst index fbf54629e..450b15e49 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Contents token_types blacklist_app stateless_user_authentication + cookie_authentication development_and_contributing drf_yasg_integration rest_framework_simplejwt From a4af5ae9337ea5be90bf2d85f824781c596943e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:11:25 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rest_framework_simplejwt/settings.py | 2 +- tests/test_authentication.py | 2 +- tests/urls.py | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/rest_framework_simplejwt/settings.py b/rest_framework_simplejwt/settings.py index f4f9555a8..64a033e0c 100644 --- a/rest_framework_simplejwt/settings.py +++ b/rest_framework_simplejwt/settings.py @@ -47,7 +47,7 @@ "CHECK_REVOKE_TOKEN": False, "REVOKE_TOKEN_CLAIM": "hash_password", "CHECK_USER_IS_ACTIVE": True, - "AUTH_COOKIE": "access" + "AUTH_COOKIE": "access", } IMPORT_STRINGS = ( diff --git a/tests/test_authentication.py b/tests/test_authentication.py index fae75b667..5c960bf00 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -3,11 +3,11 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from rest_framework.request import Request from rest_framework.test import APIRequestFactory from rest_framework_simplejwt import authentication from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken -from rest_framework.request import Request from rest_framework_simplejwt.models import TokenUser from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, SlidingToken diff --git a/tests/urls.py b/tests/urls.py index 9c86aaff6..b5dc04646 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -18,5 +18,9 @@ re_path(r"^token/verify/$", jwt_views.token_verify, name="token_verify"), re_path(r"^token/blacklist/$", jwt_views.token_blacklist, name="token_blacklist"), re_path(r"^test-view/$", views.test_view, name="test_view"), - re_path(r"^cookie-protected/$", views.cookie_protected_view, name="cookie_protected_view"), + re_path( + r"^cookie-protected/$", + views.cookie_protected_view, + name="cookie_protected_view", + ), ]