Django: Detect the global privacy control signal

Beep beep don’t track me!

Global Privacy Control (GPC) is a specification for web browsers to signal website operators not to share or sell the user’s data. This signal is intended to exercise legal data privacy rights such as those provided by the California Consumer Privacy Act (CCPA) or the EU’s General Data Protection Regulation (GDPR). While GPC is a proposal, support has been implemented in Firefox and several other privacy-focused browsers and extensions.

The GPC C specification is deliberately simple to implement with only a few moving pieces. Everything is covered in the implementation guide and demo site.

In this post, we’ll look at implementing GPC within a Django project with code samples you can adapt. Because GPC is simple but requires very situation-dependent actions, it would be hard to build any specific support into Django or a third-party package.

The GPC signal (sec-gpc header)

When enabled, the browser sends a sec-gpc header with a value of 1, known as the GPC signal. It’s up to your site to save that the signal was seen for the user and act accordingly, depending on applicable regulations. You can check the header in a Django view like so:

def index(request):
    ...
    gpc_enabled = request.headers.get("sec-gpc", "") == "1"
    if gpc_enabled:
        stop_selling_the_data_of(request.user)
    ...

I think the most likely use case is storing that the GPC signal was seen on a user’s profile. This can be used later to filter users for applicable data export processes. For example, you might save when you first saw a user send the signal with a field on your custom user model:

from django.contrib.auth.models import AbstractBaseUser
from django.db import models


class User(AbstractBaseUser):
    ...
    gpc_enabled = models.DateTimeField(null=True)

Then, a custom middleware could do a check-and-save on each request:

from django.utils import timezone


def gpc_middleware(get_response):
    """
    Save the Global Privacy Control signal when seen on a user.
    """

    def middleware(request):
        gpc_enabled = request.headers.get("sec-gpc", "") == "1"
        if (
            request.user.is_authenticated
            and gpc_enabled
            and request.user.gpc_enabled is None
        ):
            request.user.gpc_enabled = timezone.now()
            request.user.save(update_fields=("gpc_enabled",))

        return get_response(request)

    return middleware

Here are some tests that exercise all the scenarios in this middleware:

import datetime as dt

from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.test import RequestFactory
from django.test import TestCase

from example.middleware import gpc_middleware
from example.models import User


class GpcMiddlewareTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(username="gamora")

        def dummy_view(request):
            return HttpResponse("Hello")

        cls.middleware = gpc_middleware(dummy_view)

    def make_request(self, headers=None, user=None):
        request = RequestFactory().get("/", headers=headers)
        if user is None:
            user = User.objects.get(username="gamora")
        request.user = user
        return request

    def test_not_logged_in(self):
        request = self.make_request(user=AnonymousUser())

        self.middleware(request)

        self.user.refresh_from_db()
        assert self.user.gpc_enabled is None

    def test_signal_absent(self):
        request = self.make_request()

        self.middleware(request)

        self.user.refresh_from_db()
        assert self.user.gpc_enabled is None

    def test_signal_present(self):
        request = self.make_request(headers={"sec-gpc": "1"})

        self.middleware(request)

        self.user.refresh_from_db()
        assert self.user.gpc_enabled is not None

    def test_signal_present_already_saved(self):
        original = dt.datetime(2023, 12, 23, tzinfo=dt.timezone.utc)
        self.user.gpc_enabled = original
        self.user.save(update_fields=("gpc_enabled",))

        request = self.make_request(headers={"sec-gpc": "1"})

        self.middleware(request)

        self.user.refresh_from_db()
        assert self.user.gpc_enabled == original

Serving the GPC support resource

To indicate that your site supports GPC, you can serve the “GPC support resource”, a JSON document at the URL path .well-known/gpc.json. I covered a pattern for serving .well-known URLs in a previous post. Adapting that pattern for this resource, you’d get a view like this:

from django.http import JsonResponse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_GET


@require_GET
@cache_control(max_age=60 * 60)
def gpc_json(request):
    return JsonResponse(
        {
            "gpc": True,
            "lastUpdate": "2023-12-23",  # set to the appropriate date
        }
    )

With a urlconf entry like:

from django.urls import path
from example import views

urlpatterns = [
    # ...
    path(".well-known/gpc.json", views.gpc_json),
    # ...
]

Here’s a quick test to check this works:

from http import HTTPStatus

from django.test import TestCase


class GpcJsonTests(TestCase):
    def test_get(self):
        response = self.client.get("/.well-known/gpc.json")

        assert response.status_code == HTTPStatus.OK
        assert response["content-type"] == "application/json"
        assert response.json()["gpc"] is True

JavaScript API

There’s also a specification for detecting the signal in JavaScript, which may be helpful if you rely on client-side tracking:

if (navigator.globalPrivacyControl) {
  stopSellingUserData()
}

See the demo site and implementation guide for details.

Fin

I am excited to see this new privacy control developing, and I hope it gets more regulatory backing. This should help make the web a bit nicer to future users, especially if more browsers enable it by default.

May you protect your users’ privacy and your own,

Adam


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: