diff --git a/activate_account_project/settings/base.py b/activate_account_project/settings/base.py
index c1e4258d71d4ab03667f8a5f305609bde5fddb4b..83fe3c2fcd50b24a9c9f06957c4f67da6b0675d3 100644
--- a/activate_account_project/settings/base.py
+++ b/activate_account_project/settings/base.py
@@ -1,8 +1,10 @@
 import os
 import sys
+from datetime import timedelta
 
 import externalsettings
 import structlog
+from rest_framework.settings import api_settings
 
 from api.versions import AVAILABLE_VERSIONS
 
@@ -49,20 +51,18 @@ ALLOWED_HOSTS = ["*"]
 
 #: Installed applications
 INSTALLED_APPS = [
-    "django.contrib.admin",
     "django.contrib.auth",
     "django.contrib.contenttypes",
-    "django.contrib.sessions",
-    "django.contrib.messages",
     "whitenoise.runserver_nostatic",  # use whitenoise even in development
     "django.contrib.staticfiles",
     "crispy_forms",
     "django_filters",
     "drf_spectacular",
     "rest_framework",
-    "rest_framework.authtoken",
+    "knox",
     "activate_account",
     "api",
+    "authentication",
 ]
 
 #: Installed middleware
@@ -70,12 +70,9 @@ MIDDLEWARE = [
     "django.middleware.security.SecurityMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
     "whitenoise.middleware.WhiteNoiseMiddleware",
-    "django.contrib.sessions.middleware.SessionMiddleware",
     "corsheaders.middleware.CorsMiddleware",
     "django.middleware.common.CommonMiddleware",
     "django.middleware.csrf.CsrfViewMiddleware",
-    "django.contrib.auth.middleware.AuthenticationMiddleware",
-    "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
     "django_structlog.middlewares.RequestMiddleware",
 ]
@@ -143,15 +140,17 @@ STATIC_URL = "/static/"
 
 #: Authentication backends
 AUTHENTICATION_BACKENDS = [
-    "django.contrib.auth.backends.ModelBackend",
+    "authentication.backend.ActivateAccountBackend",
 ]
 
 # Configure DRF to use Django's session authentication to determine the current user
 REST_FRAMEWORK = {
     "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
     "DEFAULT_AUTHENTICATION_CLASSES": [
-        "rest_framework.authentication.SessionAuthentication",
-        "api.authentication.BearerTokenAuthentication",
+        "knox.auth.TokenAuthentication",
+    ],
+    "DEFAULT_PERMISSION_CLASSES": [
+        "rest_framework.permissions.IsAuthenticated",
     ],
     "DEFAULT_RENDERER_CLASSES": [
         "djangorestframework_camel_case.render.CamelCaseJSONRenderer",
@@ -165,6 +164,22 @@ REST_FRAMEWORK = {
     "ALLOWED_VERSIONS": AVAILABLE_VERSIONS,
 }
 
+KNOX_TOKEN_MODEL = "knox.AuthToken"
+
+REST_KNOX = {
+    "SECURE_HASH_ALGORITHM": "hashlib.sha512",
+    "AUTH_TOKEN_CHARACTER_LENGTH": 64,
+    "TOKEN_TTL": timedelta(hours=1),
+    "USER_SERIALIZER": "knox.serializers.UserSerializer",
+    "TOKEN_LIMIT_PER_USER": None,
+    "AUTO_REFRESH": False,
+    "AUTO_REFRESH_MAX_TTL": None,
+    "MIN_REFRESH_INTERVAL": 60,
+    "AUTH_HEADER_PREFIX": "Token",
+    "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT,
+    "TOKEN_MODEL": "knox.AuthToken",
+}
+
 # settings for the OpenAPI schema generator
 # https://drf-spectacular.readthedocs.io/en/latest/settings.html?highlight=settings
 SPECTACULAR_SETTINGS = {
diff --git a/activate_account_project/urls.py b/activate_account_project/urls.py
index c941a64a4a21f315dc49d66adc9d318602ad6cd7..cbee72620b5e8f01c3d3b358d861ffc05f46e9d8 100644
--- a/activate_account_project/urls.py
+++ b/activate_account_project/urls.py
@@ -13,8 +13,8 @@ Including another URLconf
     1. Import the include() function: from django.urls import include, path
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
 """
+
 from django.conf import settings
-from django.contrib import admin
 from django.http import HttpResponse
 from django.urls import include, path, re_path
 
@@ -27,14 +27,13 @@ except ImportError:
     HAVE_DDT = False
 
 urlpatterns = [
-    path("admin/", admin.site.urls),
-    path("accounts/", include("django.contrib.auth.urls")),
     # use `healthy` rather than `healthz` as Cloud Run reserves the use of the `/healthz` endpoint
     path(
         "healthy",
         lambda request: HttpResponse("ok", content_type="text/plain"),
         name="healthy",
     ),
+    path("authentication/", include("authentication.urls")),
     # You'll also need to update api/tests/test_versions_view.py
     # Include the base API urls - which gives a view of the available API versions
     path("", include("api.urls")),
diff --git a/api/authentication.py b/api/authentication.py
deleted file mode 100644
index 6ff36d8707567ada6a4fed8ea9d2e6397fde2484..0000000000000000000000000000000000000000
--- a/api/authentication.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-Custom authentication providers.
-
-"""
-from rest_framework.authentication import TokenAuthentication
-
-
-class BearerTokenAuthentication(TokenAuthentication):
-    """
-    Custom version of DRF's standard TokenAuthentication which uses the keyword "Bearer" so that
-    the required header becomes "Authorization: Bearer [...]". This is more in line with modern
-    practice.
-
-    """
-
-    keyword = "Bearer"
diff --git a/authentication/__init__.py b/authentication/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/authentication/authentication.py b/authentication/authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ab61863625cb285cb97f075e8135bdeecdec6f0
--- /dev/null
+++ b/authentication/authentication.py
@@ -0,0 +1,25 @@
+from django.contrib.auth import authenticate as django_authenticate
+from django.utils.translation import gettext_lazy as _
+from rest_framework import exceptions
+from rest_framework.authentication import BaseAuthentication, get_authorization_header
+
+
+class ActivateAccountAuthentication(BaseAuthentication):
+    def authenticate(self, request):
+        auth = get_authorization_header(request).split()
+
+        if not auth or auth[0].lower() != b"activate":
+            return None
+
+        if len(auth) == 1:
+            msg = _("Invalid activate account header. No credentials provided.")
+            raise exceptions.AuthenticationFailed(msg)
+        elif len(auth) > 2:
+            msg = _(
+                "Invalid activate account header. Credentials string should not contain spaces."
+            )
+            raise exceptions.AuthenticationFailed(msg)
+
+        user = django_authenticate(request=request, username=auth[1].decode("utf-8"))
+
+        return (user, None)
diff --git a/authentication/backend.py b/authentication/backend.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e2c50ac4737cad93d8da26dcb076976266117a7
--- /dev/null
+++ b/authentication/backend.py
@@ -0,0 +1,22 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import BaseBackend
+
+UserModel = get_user_model()
+
+
+class ActivateAccountBackend(BaseBackend):
+    def authenticate(self, request, username=None, **kwargs):
+        if username == "test":
+            # _default_manager is a documented property:
+            # https://docs.djangoproject.com/en/5.1/topics/db/managers/#django.db.models.Model._default_manager
+            try:
+                return UserModel._default_manager.get_by_natural_key(username)
+            except UserModel.DoesNotExist:
+                pass
+        return None
+
+    def get_user(self, user_id):
+        try:
+            return UserModel._default_manager.get(pk=user_id)
+        except UserModel.DoesNotExist:
+            return None
diff --git a/authentication/urls.py b/authentication/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b63428be8e6a9a2d696276b31d14817e5a60b13
--- /dev/null
+++ b/authentication/urls.py
@@ -0,0 +1,10 @@
+from django.urls import path
+from knox import views as knox_views
+
+from .views import LoginView
+
+urlpatterns = [
+    path("login/", LoginView.as_view(), name="knox_login"),
+    path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"),
+    path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"),
+]
diff --git a/authentication/views.py b/authentication/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..ada3cf01ad5dc092c80a493c48bb2969d7021858
--- /dev/null
+++ b/authentication/views.py
@@ -0,0 +1,8 @@
+from knox.views import LoginView as KnoxLoginView
+
+from .authentication import ActivateAccountAuthentication
+
+
+class LoginView(KnoxLoginView):
+    authentication_classes = [ActivateAccountAuthentication]
+    versioning_class = None
diff --git a/docker-compose.yml b/docker-compose.yml
index 2cc3b8ac6b3da41c0946ae45f2569124184781ad..911751b2ad61d867ab4996cbf9ab30e09ee965f1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,29 +32,13 @@ services:
     profiles:
       - development
     ports:
-      - 9000:8000
+      - 8000:8000
       - 5678:5678
     depends_on:
       <<: *webapp-depends-on
-      gateway-emulator:
-        condition: service_healthy
     volumes:
       - .:/usr/src/app:ro
 
-  gateway-emulator: &gateway-emulator
-    image: >-
-      registry.gitlab.developers.cam.ac.uk/uis/devops/api/api-gateway-emulator/main:latest
-    profiles:
-      - development
-    environment: &gateway-emulator-environment
-      GATEWAY_SCOPES: "activate_account"
-      GATEWAY_PROXY_DESTINATION_URL: "http://webapp:8000/"
-    healthcheck:
-      test: ["CMD", "wget", "--spider", "http://0.0.0.0:9001/proxy/status"]
-      interval: 10s
-    ports:
-      - 8000:9000
-      - 8001:9001
 
   # App running in production mode. This can be run via:
   #   docker compose --profile production up --build
@@ -64,22 +48,12 @@ services:
       context: .
     depends_on:
       <<: *webapp-depends-on
-      gateway-emulator-production:
-        condition: service_healthy
     profiles:
       - production
     environment:
       <<: *django-application-environment
     volumes: []
 
-  gateway-emulator-production:
-    <<: *gateway-emulator
-    profiles:
-      - production
-    environment:
-      <<: *gateway-emulator-environment
-      GATEWAY_PROXY_DESTINATION_URL: "http://webapp-production:8000/"
-
   # Service to allow management commands to be run via:
   #   docker compose run --build --rm manage ...
   manage:
diff --git a/poetry.lock b/poetry.lock
index 890dc0631d5ab8f067c1aaf025cd50b216172937..e2d4668c4bcbdbd658217faba3d3f307cae6cf55 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -397,6 +397,21 @@ files = [
 [package.dependencies]
 python-ipware = ">=2.0.3"
 
+[[package]]
+name = "django-rest-knox"
+version = "5.0.2"
+description = "Authentication for django rest framework"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "django_rest_knox-5.0.2-py3-none-any.whl", hash = "sha256:694da5d0ad6eb3edbfd7cdc8d69c089fc074e6b0e548e00ff2750bf2fdfadb6f"},
+    {file = "django_rest_knox-5.0.2.tar.gz", hash = "sha256:f283622bcf5d28a6a0203845c065d06c7432efa54399ae32070c61ac03af2d6f"},
+]
+
+[package.dependencies]
+django = ">=4.2"
+djangorestframework = "*"
+
 [[package]]
 name = "django-structlog"
 version = "5.3.0"
@@ -1959,4 +1974,4 @@ brotli = ["brotli"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "03996e77d677705e0cb4ac0e39b85a09776c1c4c9f52929d264ded31fc7ace4f"
+content-hash = "6952e0fd9c9029bf44a8075ded472e59f60b464ed99f062b3cb7988840862b11"
diff --git a/pyproject.toml b/pyproject.toml
index b7b0bd189ed0002847630b14972ddcc9b9069f4e..b4655e313907940c6075cf752c48de36811a013a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -105,6 +105,7 @@ proxyprefix = "^0.1.3"
 whitenoise = "^6.5.0"
 setuptools = "^73.0.1" # Added to resolve ImportError of 'pkg_resources' after Gunicorn update due to CVE-2024-1135.
 factory-boy = "^3.3.1"
+django-rest-knox = "^5.0.2"
 
 [tool.poetry.group.dev.dependencies]
 # Dependencies for running the web application in local development.