Security and data handling

Beaconry handles two sensitive things: the API credentials that connect your site to ten ad platforms, and the visitor PII that makes conversions match. This page documents exactly how both are protected, end to end. Every credential is encrypted at rest, two of the ten channels never let your site hold an OAuth token at all, every event is consent-gated, and visitor PII is SHA-256 hashed before it leaves your server.

Reading time: ~8 minLast updated: 2026-06-08

The short version

  • Credentials at rest: every API secret, access token, OAuth bearer and the license key is stored AES-256-GCM encrypted in the database, keyed off your WordPress auth salt.
  • Google Ads and Microsoft Ads: your site never holds the OAuth tokens. They live in Beaconry's broker; the plugin holds only a signed bearer that authorises uploads on your behalf.
  • Consent: no analytics consent in the visitor's nl_pref cookie means no event leaves the browser and no server-side dispatch happens. The gate is enforced centrally, once.
  • PII: email, phone, name, address and the rest are normalised and SHA-256 hashed per each channel's matching spec. The raw values never go on the wire.
  • Erasure: a built-in right-to-be-forgotten tool computes the exact hashes each vendor needs and audit-logs the request, without ever storing the plaintext.

Credential encryption at rest (AES-256-GCM)

Every value that could authenticate to a third party is encrypted before it touches the database. That covers the GA4 API secret, the Meta CAPI token, the TikTok access token, the LinkedIn access token, the Pinterest / Snapchat / Reddit access tokens, the four X Ads OAuth 1.0a secrets, both ad-broker bearers, and the Polar license key.

The cipher is AES-256-GCM, authenticated encryption. GCM gives you confidentiality and integrity in one pass: a ciphertext that has been tampered with fails the authentication tag check on decrypt and is rejected, rather than silently decrypting to garbage. Each value gets a fresh 12-byte random IV, and the 16-byte GCM tag is stored alongside the ciphertext.

The encryption key is derived from wp_salt('auth'), the per-install authentication salt every WordPress site already generates in wp-config.php. That means the key never ships with the plugin and is never the same across two sites: a database dump from one install cannot be decrypted without that install's salts. Stored values carry an encv2: prefix so the format is self-describing.

PropertyValue
AlgorithmAES-256-GCM (authenticated)
Key sourcewp_salt('auth'), per-install
IV12 bytes, fresh random per value
Auth tag16 bytes, stored with the ciphertext
Stored shapeencv2: + base64(IV + tag + ciphertext)

Legacy CBC blobs migrate on read

Installs from before the GCM switch stored secrets as AES-256-CBC (a 16-byte IV, prefix enc:, no authentication tag). CBC without a MAC is vulnerable to padding-oracle style tampering, which is why Beaconry moved off it. You do not have to do anything about old values: the decrypt path detects the prefix and reads either format, and the next time that field is saved it is re-encrypted as GCM. So CBC blobs disappear from the database gradually as you touch each setting, with no manual migration and no reconnect.

What is not stored at all

The strongest protection is not encrypting a secret, it is never holding it. Two channels are built that way (see the next section), and visitor PII is never persisted by the dispatch pipeline in the first place: it is hashed in flight and forwarded, not written to a Beaconry table. The operational log records event outcomes (health, dispatch failures, rejected requests), never raw event PII.

The OAuth-broker trust model (Google Ads + Microsoft Ads)

Google Ads and Microsoft Ads need a confidential OAuth client: a client secret, a developer token, and a long-lived refresh token. Shipping those to thousands of customer installs would be a non-starter, every copy would be a place the secret could leak from. So Beaconry does not ship them. They live in a single confidential-client broker that Beaconry operates at oauth.beaconry.app.

Your site connects once through a standard OAuth redirect. At the end of it the broker does not hand your site the Google or Microsoft tokens. It mints a random opaque site id, stores the refresh token against that id in its own key-value store, and returns a short signed bearer to your plugin instead. Every later conversion upload from your site sends that bearer; the broker verifies it, looks up the refresh token, mints a fresh access token, attaches the developer token, and posts to the ad platform on your behalf. Your wp-config.php and your database hold zero Google or Microsoft secrets, only the bearer (itself encrypted at rest like every other credential).

What the bearer is, and what it is not

The bearer is a JWT signed with HMAC-SHA256 (HS256) using a signing key that exists only inside the broker. Its payload is deliberately tiny: an opaque site id (sid), the site host, an issued-at timestamp, and an expiry (bearers carry an expiry claim of around five years; an expired bearer is rejected). It is not a Google token, it carries no Google or Microsoft scope, and it is useless against any endpoint other than the broker's own upload route. The signature means a customer cannot forge or alter one; the broker rejects anything whose HMAC does not verify.

Hardening that limits blast radius if a bearer leaks

  • Tokens never travel in a URL. For the ad-broker flow the bearer is fetched by a server-to-server POST to the broker's /redeem endpoint (the one-time OAuth state is exchanged for the bearer in a JSON body, then the state key is deleted), so the bearer is never placed in a redirect URL where it could land in a browser history, a proxy log, or a referrer header.
  • Lazy account pin on first use. The first conversion upload a bearer makes pins it to that Ads account id. Every later upload must match the pin or the broker returns 403. If a bearer ever leaked, an attacker could not point it at a different Ads account they happen to control to inject or scrape conversions. The pin is cleared on disconnect, so a clean reconnect starts a fresh pin window.
  • One-way revoke. Disconnecting from the Tracking tab tells the broker to revoke and wipe the stored refresh token and clear the pin, then clears the bearer locally. There is no path back to the tokens from the plugin side.

The eight other channels (GA4, Meta, TikTok, LinkedIn, Pinterest, Snapchat, Reddit, X Ads) authenticate with a token you paste in directly, which is then encrypted at rest as described above. They do not use the broker because their APIs accept a long-lived token or a per-call signature that is safe to hold encrypted on your own server. LinkedIn's 60-day token additionally surfaces a days-remaining counter so you re-auth before it lapses.

The consent gate

Beaconry will not dispatch a thing without consent. The bundled consent banner (nl-data-gate) writes an nl_pref cookie that records the visitor's choice across functional, analytics and marketing categories. Two checks read it:

  1. Analytics gate on the REST endpoint. Every browser event posts to your own same-origin endpoint. Before anything else happens, the endpoint reads nl_pref and drops the event silently unless the analytics flag is set. No consent, no event, full stop. This is not a cosmetic banner over a tracker that fires anyway, the data physically does not leave the browser until the visitor opts in.
  2. Marketing gate on the first-party match cookie. The optional cross-event match feature (which remembers a visitor's hashed match keys between, say, a form submit and a later checkout to lift match quality) only writes its first-party cookie when the separate marketing flag is set. Analytics consent alone does not trigger it.

Server-side callers (a form submission, a WooCommerce purchase) do not re-prompt at their own layer, because the visitor has already acted in a context where consent applies. Consent is enforced once, centrally, in the dispatcher, the same gate that covers commerce and custom events. The net effect is one place to reason about, not a scatter of per-channel checks that can drift.

If you run your own consent management platform, point it at the same nl_pref cookie and Beaconry honours your CMP's decision without a second banner.

How visitor PII is hashed

When an event carries user data (a form lead, a purchase with a billing address), Beaconry hashes it per channel before transmission. The advertiser-match fields it recognises are email, phone, first name, last name, city, state, ZIP, country, gender, date of birth, and an external customer id.

Hashing is SHA-256 over a normalised value. Normalisation matters as much as the hash: the same logical value has to produce the same digest no matter which source it came from, or audiences never match. So before hashing, Beaconry:

  • lowercases and trims the value (the baseline rule the platforms specify),
  • strips a phone number down to E.164 digits (no spaces, no leading plus) so a +49 170 1234567 and a 0170 1234567 can reconcile,
  • canonicalises country, state and ZIP, so a German state given as Niedersachsen, NI or ni resolves to one audience entry across Meta, TikTok, Pinterest and Snap,
  • reduces gender to a single f / m and date of birth to YYYYMMDD before the hash.

Each channel then receives the hashed fields in the exact key names and shape its CAPI expects (Meta's em / ph / fn / ..., GA4's sha256_email_address, LinkedIn's hashed user-id matchers, and so on). The raw, unhashed values are never sent to any platform and are never stored by Beaconry. A field you mark Don't map in the Forms tab is excluded from hashing entirely, even if its name still looks like PII, see the Forms documentation for the per-field skip marker.

Endpoint hardening

The same-origin endpoint that receives browser events is the one externally reachable surface, so it is locked down accordingly. It always answers 204 No Content regardless of outcome, so a probe cannot tell a dropped event from an accepted one. On top of the consent gate, it enforces:

  • A rotating nonce. Each event must carry a valid WordPress nonce (12-hour rotation) that the page emitted. A bare POST from the open internet without the nonce is dropped. This is what stops a stranger scripting fake conversions to burn your ad-platform quota or poison your GA4 reports. Long-open or cached pages fetch a fresh nonce automatically so legitimate tracking never lapses.
  • A bot filter. Obvious crawler, preview and scanner traffic is dropped before it can touch a single ad platform, which keeps quota and data clean. It is filterable for unusual setups.
  • A per-IP rate limit. A soft cap (600 events per minute by default, filterable) catches abusive bursts well above any real visitor's pace. The bucket key is a hash of the IP, so no raw IP is persisted.
  • Spoofing-resistant client IP. Proxy headers like X-Forwarded-For and CF-Connecting-IP are only trusted when the direct connection actually comes from a Cloudflare edge range. A direct-connect attacker cannot forge a header to dodge the rate limit, because their spoofed value is discarded in favour of the real connecting address.
  • No caching. The endpoint marks its responses no-store so a CDN or full-page cache can never serve a stale nonce or swallow an event.

GDPR right-to-be-forgotten tool

When a visitor sends a "delete my data" request, the awkward part is not your own database (Beaconry stores no visitor PII there), it is the ten ad platforms you forwarded hashed data to, each with its own deletion UI that takes hashed input. Beaconry's tool, on the Advanced tab, closes that gap.

You enter the visitor's email and / or phone. The tool computes the SHA-256 hashes using the exact same normalisation the dispatchers use at upload time, so the hash you paste into a vendor's privacy UI matches the hash that vendor stored. It then renders a per-destination workflow card (only for the channels you have actually configured) so you are not hunting through ten vendor docs to find each deletion form.

Crucially, the request is audit-logged for your own compliance record, but the log stores only the hashes and the WordPress user id of the admin who ran it, never the plaintext email or phone. The plaintext result is shown once, in a one-shot transient that is consumed and deleted on the next page load. So running an erasure request does not itself create a new place where the visitor's raw PII sits around.

Power-user controls

Two switches are worth knowing for hardened or compliance-driven setups, both set in wp-config.php:

  • Credentials out of the database entirely. Any channel credential can be supplied as a wp-config.php constant instead of through the UI. When the constant is defined and non-empty it wins over the stored value at read time, and the field renders locked in the admin. This keeps secrets in your infrastructure-as-code or your server config rather than the WordPress options table, useful when the database is backed up or replicated more widely than the codebase.
  • Disable PII persistence. The cross-event match cookie can be turned off if your privacy policy does not permit persisting visitor match keys, even encrypted and even consent-gated. Server-side conversion tracking continues to work without it, you trade a slice of match quality for zero PII persistence.

A note on Beaconry's own marketing site: it ships no tracking pixel, no analytics, and no cookie banner. Selling server-side privacy tooling and then dropping a third-party pixel on your visitors would be a contradiction, so we do not.