Django: Detect the global privacy control signal
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.
One summary email a week, no spam, I pinky promise.
Related posts:
- Django: Add a .well-known URL
- How to add a robots.txt to your Django site
- How to Score A+ for Security Headers on Your Django Website
Tags: django