Skip to content

feat(messaging): optional identity verification for push subscriptions - #72488

Merged
dmarchuk merged 4 commits into
masterfrom
claude/push-identity-verification
Jul 27, 2026
Merged

feat(messaging): optional identity verification for push subscriptions#72488
dmarchuk merged 4 commits into
masterfrom
claude/push-identity-verification

Conversation

@dmarchuk

@dmarchuk dmarchuk commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

A push device token (FCM registration token / APNs device token) is a delivery address, not a credential — FCM/APNs hand one to any app instance, and an attacker owns their own token legitimately. Registration authenticates with the public project token, which is embedded in the mobile app and world-readable. So a caller who knows a victim's distinct_id and a configured app_id can:

  • register their own device under the victim's distinct_id and receive that user's workflow notifications (takeover), or
  • DELETE the victim's subscription and stop them receiving notifications (silence).

Both the hex and Greptile security reviews on the register/unregister endpoint flagged this. It's a real gap — but forcing authentication on everyone isn't what we want either (we deliberately keep client integration optional, like Customer.io).

Changes

An opt-in, per-integration signed identity token, following Braze's SDK Authentication pattern:

  • push_identity_tokens.py — the customer's backend mints a short-lived HMAC (HS256) token signed with the project secret key, asserting sub = distinct_id and app_id. PostHog re-verifies it (accepting the current or backup secret, so key rotation doesn't reject in-flight tokens). An attacker with only the public token can't forge it.
  • Graduated per-integration enforcement via push_identity_verification in the integration config:
Mode Behavior
disabled (default) token ignored — current trust-the-client behavior, nothing breaks
optional token verified and outcome recorded (metric), request still succeeds — lets a customer confirm their backend mints valid tokens before enforcing
required an unsigned or invalid request is rejected (401)
  • The check is applied to both register (POST) and unregister (DELETE), keyed by an operation metric label.
  • Defaults to disabled, so today's token-less SDK builds keep working. Turning it on is a per-integration config flip; the SDK adds the token as one more field in the same JSON body — additive, no contract change.

The register/unregister endpoint this hardens landed in #72490 (now on master); this PR is rebased on top of it.

How did you test this code?

I (Claude) could not run the Django suite here (no Postgres), so the app-side tests rely on CI. ruff clean.

  • test_push_identity_tokens.py (pure SimpleTestCase, no DB) — the signature/claim matrix on verify_push_identity_token: valid current-secret and backup-secret tokens pass; wrong sub, wrong app_id, wrong secret, expired, missing-exp, malformed all fail. Catches any regression that would let a token authorize a (distinct_id, app_id) it wasn't minted for.
  • test_push_subscriptions.py (endpoint wiring guards) — required accepts a valid token and rejects both a missing token and a token minted for a different distinct_id (the actual attack, capture never called), for register and unregister; optional stores without a token. These prove the mode wiring end to end for both operations.

Verified end to end by the client libraries team. The SDK client half is implemented and tested against this exact contract — iOS PostHog/posthog-ios#735 and Android PostHog/posthog-android#656. Confirmed aligned: the token is sent as identity_token on both the register POST and unregister DELETE; the host app mints it via a pushIdentityProvider(distinctId, appId, completion) hook; and on a 401 the SDK drops its per-(distinctId, appId) cache, re-mints, and retries once before halting for the session. Those are the last two SDK pieces before the release PRs.

Invoked /writing-tests; pushed the claim matrix down to the pure test and kept the endpoint cases as thin wiring guards.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

No user-facing posthog.com doc yet — that lands with the customer-facing Channels UI toggle and SDK support. The token format, claims, and rollout modes are documented inline in push_identity_tokens.py.

🤖 Agent context

Autonomy: Human-driven (agent-assisted). Directed by @dmarchuk, assigned as DRI.

Authored with Claude Code. Skills invoked: /writing-tests. This revives the signed-token mechanism from the closed draft #70678 (register-only) and extends it to the unregister endpoint added in #72490, in response to the hex/Greptile findings on that work. Chose HMAC off the existing project secret over an asymmetric key pair (PostHog verifies its own ingestion, so there's no third-party verifier needing a public key). Kept enforcement disabled by default and per-integration so it's safe to enable incrementally and never forces authentication on customers who don't want it. The SDK half (attaching/refreshing the token) is owned by the mobile SDK team and verified against this contract (iOS #735, Android #656).

@dmarchuk dmarchuk self-assigned this Jul 21, 2026
@dmarchuk
dmarchuk force-pushed the claude/push-identity-verification branch from 62c9833 to 2018599 Compare July 21, 2026 06:57
@dmarchuk dmarchuk added the reviewhog ($$$) Reviews pull requests before humans do label Jul 21, 2026
@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 2 should fix, 0 consider.

Published 2 findings (view the review).

@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 2 issues

Files (2)
  • products/messaging/backend/api/push_identity_tokens.py
  • products/messaging/backend/api/push_subscriptions.py

Comment thread products/messaging/backend/api/push_identity_tokens.py
Comment thread products/messaging/backend/api/push_subscriptions.py Outdated
@dmarchuk
dmarchuk force-pushed the claude/push-identity-verification branch from 2018599 to 78bf4f9 Compare July 21, 2026 21:40
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM
@dmarchuk
dmarchuk marked this pull request as ready for review July 27, 2026 11:25
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 27, 2026 11:25
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Security Review

Identity verification can be bypassed when duplicate integrations share an app identifier because the enforcement policy is taken from an arbitrary first match.

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
products/messaging/backend/api/push_subscriptions.py:207-208
**Ambiguous integration bypasses verification**

When a team has multiple integrations with the same Firebase project ID or APNs bundle ID, `_find_integration` selects an arbitrary first match and this code uses only that record's verification mode. A disabled match can therefore override another matching integration's required policy, allowing unsigned registration or deletion requests to succeed. **How this was verified:** The lookup uses `.first()` on config identifiers that are not covered by the integration uniqueness constraint.

Reviews (1): Last reviewed commit: "Merge branch 'master' into claude/push-i..." | Re-trigger Greptile

Comment thread products/messaging/backend/api/push_subscriptions.py Outdated

@mayteio mayteio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one mate, just check the bot comments but looks good to me 😎

…te 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM
Comment thread products/messaging/backend/api/push_subscriptions.py
@veria-ai

veria-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@dmarchuk
dmarchuk enabled auto-merge (squash) July 27, 2026 11:43
@dmarchuk
dmarchuk merged commit 86ce32e into master Jul 27, 2026
240 checks passed
@dmarchuk
dmarchuk deleted the claude/push-identity-verification branch July 27, 2026 12:15
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-27 12:53 UTC Run
prod-us ✅ Deployed 2026-07-27 13:19 UTC Run
prod-eu ✅ Deployed 2026-07-27 13:21 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reviewhog ($$$) Reviews pull requests before humans do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants