From d26e57e18c61a3f985dda601d310f6a20545738c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 17 Sep 2026 02:04:04 +0100 Subject: [PATCH 01/19] DOC: Bring the PMP page up to date with the service The PMP page still showed the script tag with the resource key as a query parameter. That address answers 404 now, so a page copied from this page fetched nothing and showed no dialog. The key goes in the path instead, `https://cloud.51degrees.com/api/v4/pmp/YOUR-RESOURCE-KEY.js`, and `data-resource-key` on the tag is not read. The page also said the alternative button stores `standard`. It stores `non-marketing`, which is the visitor declining marketing, so that a publisher reading an answer shared from another site can tell a visitor who declined from one who accepted the lesser of the two kinds. Everything else on the page was checked against the service and brought up to date at the same time: - the two endpoints, the loader and the bundle it fetches, with the domain check and which of the two is counted; - the language being chosen in the browser rather than from a request header; - the 51Degrees client script belonging on the page as well, where the third party cookie result and the GDPR answer are read from; - the configuration table, which had five attributes missing and marked `data-action-url` as required when it is not, and `data-timeout`, which is no longer read; - the action URL being a hook of the publisher's own rather than the route the answer takes to the cloud, since the client script hears the answer on the window and sends it as `id.usage` itself; - `non-marketing` added to the `id.usage` mapping, with what a resource key without the 51Did product does and does not still do; - the second card that shares an answer across a group of sites, and the three things that have to hold before it is offered; - the dialog collapsing to a bubble, and the bubble being the way to change an answer without clearing storage; - the ES2020 requirement. Two other pages carried the same faults. The identifiers overview expanded PMP as "Privacy Marketing Preference" and listed two preference values, and the 51Did page said PMP fires the request itself. The UTM link lint passes, and this repository forbids campaign tags, so none were added. --- src/identifiers/fodid.md | 2 +- src/identifiers/overview.md | 4 +- src/identifiers/pmp.md | 157 +++++++++++++++++++++++++++++------- 3 files changed, 129 insertions(+), 34 deletions(-) diff --git a/src/identifiers/fodid.md b/src/identifiers/fodid.md index 70202c3cc5..148c465028 100644 --- a/src/identifiers/fodid.md +++ b/src/identifiers/fodid.md @@ -71,7 +71,7 @@ The cloud accepts two ways to decide a request's `id.usage` value. The *Direct* ### Direct - your integration owns the mapping -Your integration decides the value and tells the cloud what to do by passing an explicit `id.usage` (`non-marketing`, `standard` or `personalized`) as a query parameter or HTTP request header. You own the mapping from whatever preference or consent surface you use to one of these three values, and the cloud just acts on what you supply. This is the path @ref Identifiers_PMP takes: the widget captures the user's choice and fires the request with `id.usage` already set. +Your integration decides the value and tells the cloud what to do by passing an explicit `id.usage` (`non-marketing`, `standard` or `personalized`) as a query parameter or HTTP request header. You own the mapping from whatever preference or consent surface you use to one of these three values, and the cloud just acts on what you supply. This is the path @ref Identifiers_PMP takes. PMP captures the user's choice and announces it on the page, and the 51Degrees client script hears it and sends it as `id.usage` on its next request. ### Derived from consent - the cloud maps a TCF or GPP string for you diff --git a/src/identifiers/overview.md b/src/identifiers/overview.md index a410939ded..f674606ed8 100644 --- a/src/identifiers/overview.md +++ b/src/identifiers/overview.md @@ -1,9 +1,9 @@ @page Identifiers_Overview Overview -**51Did** (51Degrees Identifier) and **PMP** (Privacy Marketing Preference) are derived signals downstream systems can act on without seeing the raw inputs. +**51Did** (51Degrees Identifier) and **PMP** (Preference Management Platform) are derived signals downstream systems can act on without seeing the raw inputs. - **51Did** - signed identifier (a base64 OWID envelope) carrying a probabilistic value, derived from three inputs: the **Device ID** (a `Hardware-Platform-Browser-IsCrawler` tuple produced by Device Detection), the **client IP**, and the **usage purpose** (`non-marketing`, `standard`, or `personalized`) declared per request. See @ref Identifiers_51Did for the identifier-versus-value distinction. -- **PMP** - embeddable widget that captures a marketing preference (`standard` / `personalized`) suitable as `id.usage` input to 51Did. +- **PMP** - embeddable widget that captures a marketing preference (`non-marketing`, `standard` or `personalized`) suitable as `id.usage` input to 51Did. ## Flow diff --git a/src/identifiers/pmp.md b/src/identifiers/pmp.md index bb1f9750dc..063682c12e 100644 --- a/src/identifiers/pmp.md +++ b/src/identifiers/pmp.md @@ -1,23 +1,34 @@ @page Identifiers_PMP PMP (Preference Management Platform) -Lightweight embeddable widget that asks the user for a marketing preference, stores it in `localStorage`, and invokes a publisher-defined continuation URL with that preference - typically the 51Did generation endpoint. The chosen `standard` or `personalized` value is the `id.usage` input for @ref Identifiers_51Did. +PMP is a small embeddable widget that asks a visitor what kind of marketing they want, keeps the answer in `localStorage` and announces it on the page. The answer is one of `non-marketing`, `standard` or `personalized`, which are the three `id.usage` values @ref Identifiers_51Did takes. -## Endpoint +## Endpoints + +A page view makes two requests. The tag on the page fetches the loader, and the loader works out which bundle the page needs and fetches that. + +``` +GET https://cloud.51degrees.com/api/v4/pmp/.js +``` + +That returns the loader, which is about a kilobyte and the same bytes for every caller. **The resource key is the file name in the URL and nothing else on this URL is read.** Every other setting is a `data-` attribute on the script tag, so each setting is written in one place and read from one place. Any query string a page adds here is ignored and is carried nowhere. This request is neither checked nor counted, so a visitor who leaves before the page settles costs nothing. ``` -GET https://cloud.51degrees.com/api/v4/pmp?resource= +GET https://cloud.51degrees.com/api/v4/pmp// ``` -Returns the minified locale-resolved bundle. Locale is picked from the `accept-language` query parameter or the `Accept-Language` header, falling back to `en-us`. +That returns one built bundle, where `` is a locale such as `en-us`, optionally followed by `-nosharing`. The loader picks the language from the visitor's own browser, matching the full tag first, then the language on its own, then falling back to `en-us`, so the language is decided in the browser rather than from a request header. Whether the bundle needs the sharing code comes from `data-use-third-party-cookies` and `data-network-name` on the tag. The loader adds `license=` where the tag carries `data-license-key`. + +The bundle is the request that is checked and counted, because it is what the visitor actually receives. **Your resource key must be registered for the domain the page is on.** The request is checked against the domains the key names, using the `Referer` header and falling back to `Origin`, the same as every other keyed endpoint, and a page on a domain the key does not cover is refused with 401 and no dialog appears. A page that suppresses the `Referer` header, with `` or an equivalent policy, is refused for the same reason, because the check has nothing to compare. + +**An older form of this endpoint took the resource key as a query parameter, `https://cloud.51degrees.com/api/v4/pmp?resource=`. That address answers 404 now, so a page still carrying it fetches nothing and shows no dialog.** The key also has to be in the URL rather than on the tag, because `data-resource-key` is not read. Two places to write a key meant a page could carry one the cloud never saw, and the refusal that followed was invisible to the page. ## Integration -Add a single ` ``` +### The 51Degrees client script belongs on the page as well + +PMP reads two things from the 51Degrees client script, being whether third party cookies work in this browser and whether the General Data Protection Regulation applies, so put the client script on the page too: + +```html + +``` + +The two tags can go in either order and both can carry `async`, because the client script listens on the window for PMP's answer whilst PMP watches for the client script's object, which is `fod` unless `data-object-name` says otherwise. The object is the only thing PMP looks at, so it does not matter where the client script came from, and one served by a different 51Degrees cloud, by a proxy of your own or out of a bundle you built yourself all work the same way. + +An `async` tag has usually not run at the moment PMP starts, so an object that is not there yet is not an object that is never coming, and PMP waits for it instead of deciding straight away. The wait finishes once your page has loaded, since every tag written into the markup has had its turn by then, and it is capped at 5000 milliseconds for a page whose load event is very late. If nothing has appeared by the end of it, PMP loads the client script itself from the cloud that served PMP, using the resource key it already has, and says so in the console. Writing the tag yourself is still the better option, because then the placement and the timing are yours. + +Load the client script once. A second copy runs a second round and creates a second identifier, and the client script itself warns about it. + ## Configuration attributes -The full set of attributes the widget reads from its own ` +``` + +PMP reads three candidates in order and takes the first one that is present: + +1. the attribute suffixed with the whole code of the language it is running in, such as `data-dialog-heading-fr-ca` +2. the attribute suffixed with the language on its own, such as `data-dialog-heading-fr` +3. the attribute with no suffix, which is the fallback for every language that has no variant of its own + +Write none of the three and the shipped wording for that language stands, which is why leaving the attribute off altogether is the right thing to do wherever you are content with what PMP already says. + +The match ignores case, so `data-dialog-heading-FR` and `data-dialog-heading-fr` are the same attribute. A code with no region never matches a longer one, so a bundle running as `sw` does not pick up `data-dialog-heading-sw-ke`. + +An empty value is an answer rather than an absence, so `data-dialog-body=""` gives a blank paragraph instead of falling back to the shipped text. + +### Macros + +Any key written in square brackets is replaced when the card is drawn, and that applies to your own wording as much as to the text PMP ships. + +| Macro | Replaced with | +|-------|---------------| +| `[networkName]` | The value of `data-network-name`. The shipped text of the second card uses it, that being the card which asks whether the answer should apply across the group's sites. | +| `[brandName]` | The value of `data-brand-name`. | +| `[altName]` | The value of `data-alt-name`, which is the label on the alternative button. | +| `[privacyPolicyLink]` | A link to `data-brand-terms-url`, with the link text in the visitor's own language. | + +So a heading written as `Welcome to [networkName]` reaches the visitor as "Welcome to Acme Media" wherever `data-network-name` is `Acme Media`. A key that does not exist is left exactly as you wrote it, brackets included, so a typo shows up on the card rather than quietly disappearing. + +Your wording is escaped before it is placed into the card, so markup written into one of these attributes is shown to the visitor as characters instead of becoming part of the page. + +### What you cannot reword + +The two descriptions behind "More information", which say what Standard and Personalized mean, are not open to being reworded. They are the Model Terms for Marketing wording and those same two words travel onwards as `id.usage`. Were one site able to reword them, two sites could send the same value meaning different things, and nothing reading the answer afterwards could tell the difference. + ## Buttons and what each one stores | Button | Shown | Stores | From 10a71e567298addeb3ca3b216f9257679863f13d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 19 Sep 2026 21:06:56 +0100 Subject: [PATCH 03/19] DOC: Document the cookie a publisher's own server can read PMP writes the answer to a first party cookie on the publisher's domain, __mtm_pref, carrying the bare word. Nothing said so, and a publisher had no way to know the answer was reachable from their own server at all, because everything written down was localStorage and a request does not carry that. Covers what is written and why each attribute is the way it is, the new data-cookie-domain for a site served under more than one name, and which of the two copies carries the answer, being the shared one where there is one and this one otherwise. Also says that clearing the localStorage key does not leave the cookie behind, since the advice above it tells a publisher to remove that key to ask a visitor again, and a reader would otherwise reasonably wonder whether there was now a second thing to clear. --- src/identifiers/pmp.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/identifiers/pmp.md b/src/identifiers/pmp.md index f7ebac0b38..d57e8ca8c5 100644 --- a/src/identifiers/pmp.md +++ b/src/identifiers/pmp.md @@ -72,6 +72,7 @@ The attributes PMP reads from its own ` @@ -59,21 +59,21 @@ The attributes PMP reads from its own ` ``` From 62ad7d5b80a6d1e316696b850b08acda9f022870 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 21 Sep 2026 22:10:13 +0100 Subject: [PATCH 13/19] DOC: The two step verification diagram, carried over from the creator context branch --- images/51did-two-step-verification.svg | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 images/51did-two-step-verification.svg diff --git a/images/51did-two-step-verification.svg b/images/51did-two-step-verification.svg new file mode 100644 index 0000000000..2487e95426 --- /dev/null +++ b/images/51did-two-step-verification.svg @@ -0,0 +1,40 @@ + + + + + + + + + Visitor's browser + + 51Degrees cloud + + Your server + + + + Step one + + verify-context with the 51Did and the Resource Key + + { "result": "..." } sealed, unreadable in the browser + + Ordinary request to your server carrying the 51Did and the sealed result + Step two + + redeem with the 51Did, the sealed result and your licence key + within ten seconds of step one, once only + + verified, or mismatch naming the factor that differed + From 90910a57cfaa65bc45ea6e2b6678a5d642525e54 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 21 Sep 2026 22:11:02 +0100 Subject: [PATCH 14/19] DOC: 51Did as it now works, the creator context in two steps, creation only against the final device, the match key and every published reader --- src/identifiers/fodid.md | 124 ++++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 15 deletions(-) diff --git a/src/identifiers/fodid.md b/src/identifiers/fodid.md index 49de5cbfaa..37ce510650 100644 --- a/src/identifiers/fodid.md +++ b/src/identifiers/fodid.md @@ -7,9 +7,9 @@ A signed envelope, encoded as an | +| Platform | Package | Distribution | +|----------|-----------------------------------|--------------------------------------------------------------------| +| .NET | `FiftyOne.Did` | | +| Java | `com.51degrees:pipeline.did` | | +| Node.js | `fiftyone.pipeline.did` | | +| Python | `fiftyone-pipeline-did` | | +| PHP | `51degrees/fiftyone.pipeline.did` | | -Readers for other platforms are on the roadmap and will be added to this table as they are released. +Every reader exposes the same surface, set out in the [package surface](https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md) part of the specification. It parses the envelope, exposes the match key, the usage, the creation time and the signature, verifies the signature against a public key, and keeps the byte offsets out of its public surface so that application code never reads the layout by hand. The .NET package is the reference implementation. ## Comparing two 51Dids -Two 51Dids issued for the same device + IP + usage will differ at the byte level because the envelope embeds a fresh timestamp and signature on each call. The byte-level difference is in the **identifier** (the wrapper), whereas the **probabilistic value** carried inside is stable across reissues. To decide whether the two refer to the same browser instance, compare the probabilistic values, never the full base64 identifiers. +Two 51Dids issued for the same device + IP + usage will differ at the byte level because the envelope embeds a fresh timestamp and signature on each call. The byte-level difference is in the **identifier** (the wrapper), whereas the **match key** carried inside is stable across reissues. To decide whether the two refer to the same browser instance, compare the match keys, never the full base64 identifiers. -The probabilistic value is one of the fields the reader exposes after parsing the payload (per platform, named `Hash` in .NET to reflect that it is a 32-byte SHA-256). Treat it as the key for caching and for spotting duplicates. +The match key is one of the fields the reader exposes after parsing the payload, as `MatchKey` in every language. Treat it as the key for caching and for spotting duplicates. Two responses to the same device + IP + `id.usage=non-marketing`, returned a few seconds apart: @@ -191,27 +206,106 @@ var b = new FodId(idprobglobalB); Console.WriteLine(a.Date == b.Date); // false Console.WriteLine(a.Signature.SequenceEqual(b.Signature)); // false -// The probabilistic value inside the payload IS stable; this is -// what you actually compare: -Console.WriteLine(a.Hash.SequenceEqual(b.Hash)); // true +// The match key inside the payload IS stable; this is what you +// actually compare: +Console.WriteLine(a.MatchKey.SequenceEqual(b.MatchKey)); // true ``` -Use `FodId.Hash` (32 bytes, SHA-256, the probabilistic value) as the key for caching and for spotting duplicates. The same value means the same browser instance under the same usage policy on the same License Key (for `idproblic`) or across all callers (for `idprobglobal`). +Use `FodId.MatchKey` (32 bytes for the probabilistic and hashed email types) as the key for caching and for spotting duplicates. The same value means the same browser instance under the same usage policy on the same License Key (for `idproblic`) or across all callers (for `idprobglobal`). ## Validation -A 51Did recipient can optionally verify the signature before trusting the identifier. Two options: +Two things about a 51Did can be checked. The signature says whether the identifier is an authentic 51Degrees 51Did that has not been altered, and the creator context says whether it is being presented from the browser and connection it was created on. They are independent, and the second is described after the first. + +A 51Did recipient can verify the signature before trusting the identifier. Two options: 1. **Cloud endpoint.** Send the base64 value to the verification endpoint on the V4 cloud and get back a parsed payload only if the signature checks out. Simple, no key handling, but every call is metered against the Resource Key. 2. **Local verification using the published public key.** Fetch 51Degrees' public ECDSA P-256 key once, cache it, and verify in-process for every received identifier. No metering. Each platform reader (see *51Did readers* above) exposes an in-process verify method that takes the public key PEM and returns a boolean. The .NET reader's method is the inherited `Owid.VerifyAsync`. -In both cases, validation only confirms the identifier was created by 51Degrees and has not been tampered with. It does not certify that the device + IP + usage inputs were truthful, because that trust lives in the operational contract with the issuing 51Degrees cloud, not in the signature. +In both cases, signature validation only confirms the identifier was created by 51Degrees and has not been tampered with. It does not certify that the device + IP + usage inputs were truthful, because that trust lives in the operational contract with the issuing 51Degrees cloud, not in the signature. + +### Verifying the creator context + +The creator context is what the 51Degrees cloud, as the creator of the identifier, recorded about the creating request when it issued the identifier. Verifying it confirms the 51Did is being presented from the browser and connection it was created on. It is checked only within the 51Degrees service, which alone holds the key the context is made under, and every check is metered against the Resource Key. Identifiers issued by the 51Degrees cloud have carried a creator context since release 4.4.37, and one created before that, or by a self-hosted deployment with the creator context switched off, reports `nocontext`. + +The check is made in two steps, so that the verdict never exists in the browser in a form the browser can read, alter or forge. + +![Two-step creator context verification](images/51did-two-step-verification.svg) + +- **Step one.** The page calls `verify-context` (or `verify-full`) from the visitor's browser with the 51Did and the page's [Resource Key](https://51degrees.com/documentation/4.4/_info__resource_keys.html), and receives `{ "result": "..." }`, an opaque sealed value and nothing else. The call must come from the browser presenting the identifier, because the service compares the identifier against the connection making the call, so use a `fetch` that reads the JSON response rather than a script tag or a pixel. The page may also pass a `challenge`, a single-use value your server issued for this transaction, which is folded into the result. +- The page passes the 51Did and the sealed result to your server as part of its normal request, for example with the form post or the purchase the identifier is being trusted for. +- **Step two.** Your server calls `redeem` with the 51Did it holds, the sealed result, a licence key of the account whose Resource Key made the verification (required wherever the account holds licence keys, and never placed in a page) and, where a `challenge` was given at step one, the same value again, and receives the verdict. + +The endpoints on the V4 cloud, all of which take the identifier as the `51did` parameter on the query string, in a form, or as the route `51did/`: + +- `GET`/`POST` `/api/v4/id/verify` returns `{ "valid": }`, the signature result only, readable at once. +- `GET`/`POST` `/api/v4/id/verify-context` returns `{ "result": "..." }`, a sealed context result. +- `GET`/`POST` `/api/v4/id/verify-full` returns `{ "result": "..." }`, a sealed result carrying the signature result as well as the context result, so one call and one redemption give both. +- `GET`/`POST` `/api/v4/id/redeem` takes `51did`, `result`, `license` and optionally `challenge`, and returns `{ "signature": "verified" | "invalid", "context": "...", "factors": { ... }, "verifiedAt": "...", "secondsSinceVerified": }`. + +All four require a Resource Key and are metered against it. A call with no Resource Key, or whose Resource Key lacks the entitlement, returns `401`. A `license` parameter may add entitlement but is not an alternative to the Resource Key. Every call is one use, so checking the creator context from a browser costs two uses, one for the verification and one for the redemption, whereas checking the signature alone with `verify` costs one. The self-hosted container does not count uses per call. + +**Send what the page collected.** The context is compared against the device as the cloud has fully resolved it, exactly as the identifier was created against it (see *When the identifier is created*). After the client script reports complete, include every value it stored, such as `51D_ProfileIds`, `51D_ScreenPixelsWidth` and, on a Chromium browser, `51D_GetHighEntropyValues`, as parameters on the verification request. A page's verification that carries none of them, where the device then differs and the same browser made the request, is answered with a `400` naming the values to send rather than a verdict. If collection cannot complete, for example because the client script failed to load, do not verify and do not report a mismatch. Report that the check did not complete, which is not evidence either way. + +The `context` values: + +| Value | Meaning | +|-------|---------| +| `verified` | The 51Did is being presented from the browser and connection it was created on. | +| `mismatch` | At least one factor of the presenting browser or connection differs from the one recorded at creation. `factors` says which. | +| `misconfigured` | The service that checked the identifier could not complete the check, and the reason is that service rather than the identifier. Nothing a caller sends can produce it. Where some factors were compared, `factors` marks the rest `misconfigured` and shows the outcome of the ones it could check. Not a mismatch, and not to be treated as one. Against your own hosted service, its start-up log names the setting to change. | +| `invaliddate` | The identifier claims a creation date the scheme could not have produced, being in the future or before the creator context existed, so the identifier is fabricated and nothing is wrong with the service. | +| `nocontext` | The 51Did carries no creator context to check, being one created before release 4.4.37 or by a deployment with the creator context switched off. Normal rather than an error, and it says nothing about whether the identifier is genuine, which the signature answers on its own. | + +Where `context` is `mismatch`, or `misconfigured` with some factors compared, `factors` breaks the comparison down across nine independent factors named `transport`, `device`, `browserip`, `connectionip`, `asn`, `platformname`, `platformversion`, `browsername` and `browserversion`, each `verified`, `mismatch` or `misconfigured`. It is there to help you locate a problem and to weigh a mismatch. A call made from a server rather than the presenting browser, for example, shows the transport, device and connection factors as `mismatch`, the server having its own connection and device. Nothing about what a factor is made of is exposed, only whether it matched. Treat the top-level `context` value as the result. + +Read together with the signature: + +| `signature` | `context` | Meaning | +|-------------|-----------|---------| +| `verified` | `verified` | Authentic identifier presented from its creation context. | +| `verified` | `mismatch` | Authentic identifier presented from a different context. What a replay looks like, and also what a legitimate server verifying out of context sees, and that server knows which situation it is in. | +| `verified` | `nocontext` or `misconfigured` | Authentic identifier with no context this service could check. Rely on the signature alone. | +| `invalid` | any | The envelope has been altered or corrupted. A creator context cannot be forged, because it is made under a key only 51Degrees holds, so a `verified` context on an `invalid` signature means the context data is intact and something else in the envelope is not. | + +**The redemption itself.** A sealed result redeems once, within ten seconds of the verification, and each verification produces a fresh one. The window is the anti-replay window of TLS 1.3, which QUIC mandates ([RFC 8446 section 8.3](https://www.rfc-editor.org/rfc/rfc8446#section-8.3)), so the trade-off between clock error, network variation and replay exposure has already been argued, and it is a constant of the service that cannot be widened by configuration. Every clock involved is a server clock, so the visitor's browser clock plays no part. In the genuine flow the two steps happen moments apart within one page transaction, so the window costs nothing. `verifiedAt` and `secondsSinceVerified` describe the verification you have just made and let your server apply a stricter rule of its own without any clock work. They say nothing about the identifier's age, which comes from the identifier itself. + +| Response | Meaning | What to do | +| --- | --- | --- | +| `context` of `expired`, with `verifiedAt` and `secondsSinceVerified` | Genuine but older than ten seconds | Treat as unverified, and if this recurs in a genuine flow redeem sooner after the page verifies | +| `context` of `replayed` | Already redeemed on this service instance | Treat as unverified, because something presented the same result twice | +| `context` of `unreadable` | Not a result sealed for this 51Did, licence key and challenge, or altered | Treat as unverified. The service does not say which was wrong | +| `unconfirmed` (HTTP 503) | The instance could not confirm first use | Retry, as it is neither a replay nor a forgery | +| HTTP 400 naming the values to send, such as `51D_ProfileIds` | A page verified before sending what its scripts collected | Send the values the client script collected and verify again | +| HTTP 400 naming a payload status | The 51Did is not a shape the scheme produces | Treat as unverified, and check what produced the value you sent | + +The record of redemptions is held in memory per service instance rather than across regions, as TLS 1.3 notes of its own record in distributed deployments, so two redemptions of one result routed to two instances can both succeed within the window. If your flow must be single use everywhere, route redemptions for one transaction to one place or keep your own short-lived record of results already redeemed. + +**Identifiers issued before release 4.4.37.** Treat a 51Did the 51Degrees cloud issued with a creator context before that release as unverified and use a newly created 51Did in its place. Those identifiers no longer verify. + +**A mismatch is not always a problem.** The match key is what is stable, and nothing about the creator context changes it. What can change is the connection the visitor arrives on, so a mismatch says the visitor is arriving differently from before, not that the identifier is fake. A privacy relay service changes the address and the operator together. A home or mobile connection changes its address and keeps its operator, so `browserip` and `connectionip` mismatch with `asn` verified. A browser or operating system upgrade changes a version and keeps the name, so `browserversion` or `platformversion` mismatches with `browsername` and `platformname` verified. Read the factors with the identifier's age, which the identifier carries to the minute: + +| Age of the 51Did | What a mismatch suggests | +| --- | --- | +| Seconds to minutes | Treat seriously. The visitor has not moved network, no relay has rotated and no browser has updated in that time, so the likeliest explanation is that the identifier is being presented from somewhere other than where it was created. | +| Hours to a day | Worth weight. A relay rotation or a new address is possible, a browser upgrade unlikely. | +| Days to weeks | Weak on its own. Address changes and browser upgrades are both routine over this span. | +| A month or more | Expect mismatches. A verified context after this long is a strong positive, whereas a mismatch is close to uninformative on its own. | + +Match your response to what you are about to do rather than to the verdict alone. For frequency capping, measurement and reporting, an address change or an upgrade is no reason to discard the identifier. For personalisation and audience selection, carry on and lower any confidence you keep. For signing in, changing an account or taking payment, ask for a second factor of your own on an address change or an upgrade, and refuse on a changed browser name, platform name or device, or on several unrelated factors at once. An identifier made minutes ago deserves the stricter response, and one made a month ago the more lenient. The service never withholds a verdict because of the identifier's age. It reports what matched and what did not, and the decision is yours. + +**What the identifier does not tell you about its creator.** Every 51Did carries a small field 51Degrees uses to know which of its own customers created the identifier, for support and billing. It is encrypted, it changes as 51Degrees rotates the secret behind it, and it also depends on the identifier itself, so two identifiers from the same customer carry different values. A recipient cannot group identifiers by the customer that created them, cannot tell whether two came from the same customer, and cannot work out who any customer is. + +Local public-key verification (option 2 above) covers the signature only. The creator context check exists nowhere but the 51Degrees service, and is available self-hosted through the bespoke Docker solution for identifiers that deployment creates. A self-hosted instance running without TLS capture still serves the whole flow for identifiers created and verified on that same instance, which is intended for local testing, and reports through its health check that it is not capturing, so the fault reaches whoever runs the deployment rather than the caller. + +A long-lived identifier that still verifies from its creation context is the strongest signal of a stable, real user, and age cannot be manufactured. This makes context verification well suited to a render-time check. Place the 51Did from a bid request into the creative, verify it from the rendering browser, send the 51Did and the sealed result to your own endpoint, and redeem them there. A `context` of `mismatch` on an identifier made minutes earlier means the paid impression rendered somewhere other than the browser the bid described. + ### Fetching the public key for local verification Local verification (option 2 above) fetches the key from the OWID creator endpoint, `GET /owid/api/v3/creator`. The response carries the current signing key in `publicKeySPKI` (PEM). -The signing key rotates weekly, so a 51Did issued before the latest rotation was signed with an older key. To fetch the key that was current when a 51Did was created, pass its date, as `GET /owid/api/v3/creator?date=`. The `date` is the same value the OWID envelope carries in its Date field, minutes since `2020-01-01T00:00:00Z` (see the [OWID explainer](https://github.com/SWAN-community/owid/blob/main/explainer.md), "Data Structure" section). The endpoint returns the signing key with the latest creation time on or before `date`. If `date` predates every known key it returns `404`, and a `date` that is not an unsigned 32-bit integer returns `400`. +Signing keys belong to periods of a schedule, and a 51Did is signed with the key of the period its creation falls in, so a 51Did issued in an earlier period was signed with an earlier key. To fetch the key that was in force when a 51Did was created, pass its date on every request, as `GET /owid/api/v3/creator?date=`. The `date` is the same value the OWID envelope carries in its Date field, minutes since `2020-01-01T00:00:00Z` (see the [OWID explainer](https://github.com/SWAN-community/owid/blob/main/explainer.md), "Data Structure" section). The endpoint returns the signing key whose period was in force at `date`. If `date` predates every known key it returns `404`, and a `date` that is not an unsigned 32-bit integer returns `400`. ### Fetching every public key at once From 232291632a810d46980bfe6b44ff2b2934c404ca Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 21 Sep 2026 22:11:05 +0100 Subject: [PATCH 15/19] DOC: PMP as it now works, the page API, the consent surface, the regulation answer, languages, caching and the brand icon --- src/identifiers/pmp.md | 54 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/identifiers/pmp.md b/src/identifiers/pmp.md index e30a340221..4a069229af 100644 --- a/src/identifiers/pmp.md +++ b/src/identifiers/pmp.md @@ -59,9 +59,10 @@ The attributes PMP reads from its own `