From 20f86f1ef730dc0375fc9881680b9124dc97d015 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 21:56:13 +0000 Subject: [PATCH 1/2] feat(messaging): add opt-in identity verification for push registration Adds a signed identity token mechanism so customers can optionally require proof that a registration/unregistration call is authorized to act for the given distinct_id, closing the endpoint takeover vector in the default mode. The customer backend mints a short-lived HS256 token (keyed by the project's secret API key) asserting (distinct_id, app_id); the endpoint re-verifies it. Per-integration config["push_identity_verification"] selects disabled (default), optional, or required behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM --- .../backend/api/push_identity_tokens.py | 85 ++++++++++++ .../backend/api/push_subscriptions.py | 40 +++++- .../api/test/test_push_identity_tokens.py | 70 ++++++++++ .../api/test/test_push_subscriptions.py | 122 ++++++++++++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 products/messaging/backend/api/push_identity_tokens.py create mode 100644 products/messaging/backend/api/test/test_push_identity_tokens.py diff --git a/products/messaging/backend/api/push_identity_tokens.py b/products/messaging/backend/api/push_identity_tokens.py new file mode 100644 index 000000000000..25f34c600c7b --- /dev/null +++ b/products/messaging/backend/api/push_identity_tokens.py @@ -0,0 +1,85 @@ +""" +Signed identity tokens for push subscription registration. + +A push device token (FCM registration token / APNs device token) is a delivery *address*, not a +credential: FCM/APNs hand a token to any app instance that registers, and an attacker owns their own +token legitimately. So possession of a token proves "deliver to this device" — never "this device +belongs to user X". Binding a token to a `distinct_id` therefore needs proof that the caller is +allowed to act for that `distinct_id`. The public project token can't provide it: it is embedded in +the mobile app and world-readable, so anyone can present it and claim any `distinct_id`. + +Following the pattern proven by Braze's "SDK Authentication", the customer's backend — the only party +that actually authenticated the end user — mints a short-lived token asserting the user's +`distinct_id`, signed with the project's secret API key. PostHog re-verifies the signature at +registration time. An attacker holding only the public project token cannot forge it. + +We use symmetric HMAC (HS256) keyed by `Team.secret_api_token` rather than an asymmetric key pair: +PostHog verifies its own ingestion, so there is no third-party verifier that would need a public key, +and the secret already ships with built-in rotation via `secret_api_token_backup`. Verification +accepts either the current or the backup secret so a key rotation doesn't reject in-flight tokens. +""" + +from datetime import UTC, datetime, timedelta + +import jwt + +from posthog.models.team.team import Team + +PUSH_IDENTITY_TOKEN_AUDIENCE = "posthog:push_identity" +_ALGORITHM = "HS256" + +# Short TTL: the token only needs to survive the round trip from the customer's backend, through the +# app, to the registration call. Keeping it small bounds the replay window (a replay can only re-assert +# the same (distinct_id, app_id) binding the legitimate user already holds, so the value is low anyway). +DEFAULT_TTL = timedelta(minutes=5) + + +def sign_push_identity_token( + secret_api_token: str, + distinct_id: str, + app_id: str, + ttl: timedelta = DEFAULT_TTL, +) -> str: + """Mint a signed identity token. + + This is the reference implementation of what the *customer's backend* runs after it has + authenticated the end user. It is not called by PostHog's own ingestion (which only verifies); + it lives here so the signing and verification rules stay in one place and the tests can exercise + the real round trip. + """ + return jwt.encode( + { + "sub": distinct_id, + "app_id": app_id, + "aud": PUSH_IDENTITY_TOKEN_AUDIENCE, + "exp": datetime.now(UTC) + ttl, + }, + secret_api_token, + algorithm=_ALGORITHM, + ) + + +def verify_push_identity_token(token: str, team: Team, distinct_id: str, app_id: str) -> bool: + """Return True iff `token` is a valid, unexpired identity assertion for exactly this + `(distinct_id, app_id)`, signed by the team's current or backup secret API key. + + Binding the claim to `app_id` as well as `distinct_id` stops a token minted for one app being + replayed to register a device under a different app in the same project. + """ + candidate_secrets = [secret for secret in (team.secret_api_token, team.secret_api_token_backup) if secret] + for secret in candidate_secrets: + try: + payload = jwt.decode( + token, + secret, + algorithms=[_ALGORITHM], + audience=PUSH_IDENTITY_TOKEN_AUDIENCE, + # Require exp explicitly: PyJWT only checks expiry when the claim is present, so without + # this a token minted (by an external signer) with no exp would never expire. + options={"require": ["exp"]}, + ) + except jwt.InvalidTokenError: + continue + if payload.get("sub") == distinct_id and payload.get("app_id") == app_id: + return True + return False diff --git a/products/messaging/backend/api/push_subscriptions.py b/products/messaging/backend/api/push_subscriptions.py index 96ceb67bd7a2..ff120e3faf4b 100644 --- a/products/messaging/backend/api/push_subscriptions.py +++ b/products/messaging/backend/api/push_subscriptions.py @@ -4,6 +4,7 @@ from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt +from prometheus_client import Counter from rest_framework import status from rest_framework.request import Request @@ -20,6 +21,18 @@ from posthog.utils import decompress, load_data_from_request from posthog.utils_cors import cors_response +from products.messaging.backend.api.push_identity_tokens import verify_push_identity_token + +# Identity verification is opt-in per integration via config["push_identity_verification"]: +# "disabled" (default) — no token required; anyone with the public project token can register. +# "optional" — a token is verified and recorded when present, but never required. +# "required" — registration/unregistration is rejected without a valid identity token. +PUSH_IDENTITY_VERIFICATION_COUNTER = Counter( + "push_subscription_identity_verification", + "Outcome of push subscription identity token verification.", + labelnames=["mode", "operation", "outcome"], +) + VALID_PLATFORMS = ("android", "ios") # A device registration payload is a handful of short string fields (distinct_id, device_token, @@ -41,7 +54,7 @@ def _find_integration(team_id: int, app_id: str) -> Integration | None: return ( Integration.objects.filter(team_id=team_id) .filter(Q(kind="firebase", config__project_id=app_id) | Q(kind="apns", config__bundle_id=app_id)) - .only("id") + .only("id", "config") .first() ) @@ -190,6 +203,31 @@ def push_subscriptions(request: Request): ), ) + operation = "register" if request.method == "POST" else "unregister" + verification_mode = integration.config.get("push_identity_verification", "disabled") + if verification_mode in ("optional", "required"): + identity_token = data.get("identity_token") + verified = isinstance(identity_token, str) and verify_push_identity_token( + identity_token, team, distinct_id, app_id + ) + PUSH_IDENTITY_VERIFICATION_COUNTER.labels( + mode=verification_mode, + operation=operation, + outcome="verified" if verified else "unverified", + ).inc() + if not verified and verification_mode == "required": + return cors_response( + request, + generate_exception_response( + "push_subscriptions", + "A valid identity token is required for this device. Your backend must mint one for " + "the signed-in user with the project's secret API key.", + type="authentication_error", + code="identity_verification_failed", + status_code=status.HTTP_401_UNAUTHORIZED, + ), + ) + property_key = f"$device_push_subscription_{app_id}" # $unset of an absent property is a no-op, so DELETE (logout) is idempotent. device_token is diff --git a/products/messaging/backend/api/test/test_push_identity_tokens.py b/products/messaging/backend/api/test/test_push_identity_tokens.py new file mode 100644 index 000000000000..f245b8770eb2 --- /dev/null +++ b/products/messaging/backend/api/test/test_push_identity_tokens.py @@ -0,0 +1,70 @@ +from datetime import timedelta + +from django.test import SimpleTestCase + +import jwt +from parameterized import parameterized + +from posthog.models.team.team import Team + +from products.messaging.backend.api.push_identity_tokens import ( + PUSH_IDENTITY_TOKEN_AUDIENCE, + sign_push_identity_token, + verify_push_identity_token, +) + +# Realistic length (>= 32 bytes) — matches a real phs_ secret and avoids PyJWT's short-key warning. +CURRENT_SECRET = "phs_current_secret_0123456789abcdef0123" +BACKUP_SECRET = "phs_backup_secret_0123456789abcdef01234" +DISTINCT_ID = "user-1" +APP_ID = "my-firebase-project" + + +class TestPushIdentityTokens(SimpleTestCase): + def _team(self, secret: str | None = CURRENT_SECRET, backup: str | None = None) -> Team: + return Team(secret_api_token=secret, secret_api_token_backup=backup) + + def test_verifies_a_token_signed_with_the_current_secret(self): + token = sign_push_identity_token(CURRENT_SECRET, DISTINCT_ID, APP_ID) + assert verify_push_identity_token(token, self._team(), DISTINCT_ID, APP_ID) is True + + def test_verifies_a_token_signed_with_the_backup_secret_after_rotation(self): + token = sign_push_identity_token(BACKUP_SECRET, DISTINCT_ID, APP_ID) + team = self._team(secret=CURRENT_SECRET, backup=BACKUP_SECRET) + assert verify_push_identity_token(token, team, DISTINCT_ID, APP_ID) is True + + @parameterized.expand( + [ + ("wrong_distinct_id", "someone-else", APP_ID), + ("wrong_app_id", DISTINCT_ID, "other-app"), + ] + ) + def test_rejects_a_token_whose_claims_do_not_match_the_registration(self, _name, sub, app_id): + # The rebind guard: a token minted for one (distinct_id, app_id) cannot authorize a different one. + token = sign_push_identity_token(CURRENT_SECRET, sub, app_id) + assert verify_push_identity_token(token, self._team(), DISTINCT_ID, APP_ID) is False + + def test_rejects_a_token_signed_with_a_different_secret(self): + token = sign_push_identity_token("phs_attacker_secret_0123456789abcdef012", DISTINCT_ID, APP_ID) + assert verify_push_identity_token(token, self._team(), DISTINCT_ID, APP_ID) is False + + def test_rejects_an_expired_token(self): + token = sign_push_identity_token(CURRENT_SECRET, DISTINCT_ID, APP_ID, ttl=timedelta(seconds=-1)) + assert verify_push_identity_token(token, self._team(), DISTINCT_ID, APP_ID) is False + + def test_rejects_a_token_with_no_exp_claim(self): + # An external signer (customer backend / SDK) could omit exp; without requiring it a token would + # never expire, so the verifier must reject it even though the signature is valid. + token = jwt.encode( + {"sub": DISTINCT_ID, "app_id": APP_ID, "aud": PUSH_IDENTITY_TOKEN_AUDIENCE}, + CURRENT_SECRET, + algorithm="HS256", + ) + assert verify_push_identity_token(token, self._team(), DISTINCT_ID, APP_ID) is False + + def test_rejects_a_malformed_token(self): + assert verify_push_identity_token("not-a-jwt", self._team(), DISTINCT_ID, APP_ID) is False + + def test_rejects_when_the_team_has_no_secret_configured(self): + token = sign_push_identity_token(CURRENT_SECRET, DISTINCT_ID, APP_ID) + assert verify_push_identity_token(token, self._team(secret=None), DISTINCT_ID, APP_ID) is False diff --git a/products/messaging/backend/api/test/test_push_subscriptions.py b/products/messaging/backend/api/test/test_push_subscriptions.py index ae1a348ac2d5..da5d1d40ef8a 100644 --- a/products/messaging/backend/api/test/test_push_subscriptions.py +++ b/products/messaging/backend/api/test/test_push_subscriptions.py @@ -10,9 +10,15 @@ from posthog.models.integration import Integration from posthog.models.team.team import Team +from posthog.models.team.team_caching import set_team_in_cache + +from products.messaging.backend.api.push_identity_tokens import sign_push_identity_token class TestPushSubscriptionsAPI(BaseTest): + # Realistic length (>= 32 bytes) so signing/verification exercises a real phs_ secret. + SECRET = "phs_project_secret_0123456789abcdef0123" + def setUp(self): super().setUp() self.client = Client() @@ -48,6 +54,14 @@ def _delete(self, data: dict, api_key: str | None = None): content_type="application/json", ) + def _enable_identity_verification(self, mode: str): + self.firebase_integration.config["push_identity_verification"] = mode + self.firebase_integration.save() + self.team.secret_api_token = self.SECRET + self.team.save() + # The endpoint resolves the team from the token cache, so refresh it with the secret set. + set_team_in_cache(self.team.api_token, self.team) + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") def test_register_android_token(self, mock_capture: MagicMock): mock_capture.return_value = MagicMock(status_code=200) @@ -318,3 +332,111 @@ def test_oversized_body_is_rejected_before_parsing(self, mock_capture: MagicMock assert response.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE mock_capture.assert_not_called() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_required_mode_accepts_a_valid_identity_token(self, mock_capture: MagicMock): + mock_capture.return_value = MagicMock(status_code=200) + self._enable_identity_verification("required") + token = sign_push_identity_token(self.SECRET, "user-1", "my-firebase-project") + + response = self._post( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + "identity_token": token, + } + ) + + assert response.status_code == status.HTTP_200_OK + mock_capture.assert_called_once() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_required_mode_rejects_registration_without_a_token(self, mock_capture: MagicMock): + self._enable_identity_verification("required") + + response = self._post( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + } + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["code"] == "identity_verification_failed" + mock_capture.assert_not_called() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_required_mode_rejects_a_token_minted_for_another_distinct_id(self, mock_capture: MagicMock): + # The takeover guard: a token the attacker legitimately minted for their own distinct_id + # cannot authorize binding a device to the victim's distinct_id. + self._enable_identity_verification("required") + attacker_token = sign_push_identity_token(self.SECRET, "attacker", "my-firebase-project") + + response = self._post( + { + "distinct_id": "victim", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + "identity_token": attacker_token, + } + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_capture.assert_not_called() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_optional_mode_stores_even_without_a_token(self, mock_capture: MagicMock): + mock_capture.return_value = MagicMock(status_code=200) + self._enable_identity_verification("optional") + + response = self._post( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + } + ) + + assert response.status_code == status.HTTP_200_OK + mock_capture.assert_called_once() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_required_mode_rejects_unregister_without_a_token(self, mock_capture: MagicMock): + self._enable_identity_verification("required") + + response = self._delete( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + } + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_capture.assert_not_called() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_required_mode_accepts_a_valid_token_for_unregister(self, mock_capture: MagicMock): + mock_capture.return_value = MagicMock(status_code=200) + self._enable_identity_verification("required") + token = sign_push_identity_token(self.SECRET, "user-1", "my-firebase-project") + + response = self._delete( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + "identity_token": token, + } + ) + + assert response.status_code == status.HTTP_200_OK + mock_capture.assert_called_once() From 570bac83fab7e2a07b986ffc326196328299326a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:37:14 +0000 Subject: [PATCH 2/2] fix(messaging): resolve push identity mode fail-closed across duplicate integrations An app_id can match more than one integration (project_id/bundle_id aren't unique), so reading the verification mode off an arbitrary .first() match let a disabled duplicate downgrade a sibling's required policy. Resolve the strictest mode across every matching integration instead, so verification fails closed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM --- .../backend/api/push_subscriptions.py | 28 ++++++++++++++----- .../api/test/test_push_subscriptions.py | 26 +++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/products/messaging/backend/api/push_subscriptions.py b/products/messaging/backend/api/push_subscriptions.py index ff120e3faf4b..73a8db492c21 100644 --- a/products/messaging/backend/api/push_subscriptions.py +++ b/products/messaging/backend/api/push_subscriptions.py @@ -46,16 +46,30 @@ _encrypted_fields = EncryptedFieldMixin() -# Resolve the integration from the app_id alone, not the device platform. An app_id is either a +# Verification-mode precedence. An app_id can match more than one integration — config identifiers +# (project_id / bundle_id) aren't covered by a uniqueness constraint — so mode resolution must fail +# closed: take the strictest mode across every match so a lax duplicate can't downgrade a sibling's +# `required` policy. Unknown/garbage values sort to 0 (treated as disabled). +_VERIFICATION_MODE_PRECEDENCE = {"disabled": 0, "optional": 1, "required": 2} + + +# Resolve integrations from the app_id alone, not the device platform. An app_id is either a # Firebase project_id or an APNs bundle_id, so a device can register with either provider regardless # of its OS — e.g. an iOS device delivering through Firebase registers with the Firebase project_id. # (The client still sends its platform, but it's metadata, not what selects the provider.) -def _find_integration(team_id: int, app_id: str) -> Integration | None: - return ( +def _find_integrations(team_id: int, app_id: str) -> list[Integration]: + return list( Integration.objects.filter(team_id=team_id) .filter(Q(kind="firebase", config__project_id=app_id) | Q(kind="apns", config__bundle_id=app_id)) .only("id", "config") - .first() + ) + + +def _strictest_verification_mode(integrations: list[Integration]) -> str: + return max( + (integration.config.get("push_identity_verification", "disabled") for integration in integrations), + key=lambda mode: _VERIFICATION_MODE_PRECEDENCE.get(mode, 0), + default="disabled", ) @@ -189,8 +203,8 @@ def push_subscriptions(request: Request): ), ) - integration = _find_integration(team.id, app_id) - if not integration: + integrations = _find_integrations(team.id, app_id) + if not integrations: return cors_response( request, generate_exception_response( @@ -204,7 +218,7 @@ def push_subscriptions(request: Request): ) operation = "register" if request.method == "POST" else "unregister" - verification_mode = integration.config.get("push_identity_verification", "disabled") + verification_mode = _strictest_verification_mode(integrations) if verification_mode in ("optional", "required"): identity_token = data.get("identity_token") verified = isinstance(identity_token, str) and verify_push_identity_token( diff --git a/products/messaging/backend/api/test/test_push_subscriptions.py b/products/messaging/backend/api/test/test_push_subscriptions.py index da5d1d40ef8a..ae73f2e01519 100644 --- a/products/messaging/backend/api/test/test_push_subscriptions.py +++ b/products/messaging/backend/api/test/test_push_subscriptions.py @@ -440,3 +440,29 @@ def test_required_mode_accepts_a_valid_token_for_unregister(self, mock_capture: assert response.status_code == status.HTTP_200_OK mock_capture.assert_called_once() + + @patch("products.messaging.backend.api.push_subscriptions.capture_internal") + def test_strictest_mode_wins_when_two_integrations_share_an_app_id(self, mock_capture: MagicMock): + # project_id/bundle_id aren't unique, so an app_id can match several integrations. Resolution + # must fail closed: a second integration with the same project_id and verification disabled + # must not let a token-less request through when a sibling requires verification. + self._enable_identity_verification("required") + Integration.objects.create( + team=self.team, + kind="firebase", + integration_id="my-firebase-project-duplicate", + config={"project_id": "my-firebase-project", "push_identity_verification": "disabled"}, + sensitive_config={}, + ) + + response = self._post( + { + "distinct_id": "user-1", + "device_token": "fcm-device-token-abc", + "platform": "android", + "app_id": "my-firebase-project", + } + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_capture.assert_not_called()