Identify users and add context
Out of the box, Shield shows each visit as a session with an IP, a device fingerprint, a risk score, and a recommendation. It does not know which of your users that session belongs to. This page shows how to attach your own user identifier and business context so a high-risk session can be traced back to an account in your system.
The one rule: send an identifier, never an identity
Shield never stores raw emails, usernames, phone numbers, or names.
The identifier you send must be opaque: something you can resolve in your own database, but that means nothing on its own. The field for it is user_id_hash, and it is accepted when the value looks like a hash:
- hexadecimal, 32 to 128 characters (an MD5, SHA-1, SHA-256, or a UUID with the dashes removed), or
- base64, 32 characters or more.
Anything else, including a plain username or a short numeric ID, is silently dropped by both the SDK and the API.
The recommended value is a SHA-256 of your internal user ID, computed on the server. Hash the ID, not the email, and add a secret pepper so the hash cannot be reversed by guessing:
// Node.js
const crypto = require('crypto');
const userIdHash = crypto
.createHash('sha256')
.update(`${process.env.SHIELD_PEPPER}:${user.id}`)
.digest('hex');// PHP
$userIdHash = hash('sha256', getenv('SHIELD_PEPPER') . ':' . $user->id);To find the account behind a hash you see in the dashboard, compute the same hash for the user in question, or store the hash next to the user record when it is first generated.
1. Google Tag Manager template fields
No code. For sites installed with the official GTM tag template.
Open the FindIP Shield tag and expand Identify the visitor. Select the variables your site already exposes for logged-in users, typically the Data Layer Variables you use for GA4's user_id:
| Field | What to select | What Shield receives |
|---|---|---|
| User ID | e.g. {{DLV - userId}} | user_id_hash — SHA-256 of the ID |
| Email address | e.g. {{DLV - userEmail}} | email_hash — SHA-256 of the lowercased address, plus email_domain |
| Plan | e.g. {{DLV - plan}} or a constant | plan |
| Hash salt | an optional secret string | mixed into both hashes as SHA-256(salt + ':' + value) |
The tag hashes the values in the browser before the SDK loads, so the raw ID and email never leave the page, and every automatic event carries the result. To resolve a hash from the dashboard, compute the same SHA-256 (with the same salt) in your own system. If your site does not expose a user ID variable yet, ask your developer to push one to the dataLayer for logged-in users; it is the same variable GA4 uses. Installed with a GTM Custom HTML tag instead? The GTM page has a version of that snippet with the same fields as placeholders, and the dashboard's Install page generates it with your site key filled in.
2. JavaScript SDK or script tag
Push to the dataLayer before init. Works with or without GTM.
The SDK reads identification keys from the dataLayer for every automatic event. Push them before the SDK initializes: either hash server-side and render the values into the page, or hash in the browser. The dashboard's Install page generates both the npm and the script-tag snippet with an Identify logged-in visitors toggle that adds the browser-hashing version with your site key filled in.
Server-rendered hashes. Compute the hashes on the server (see above) and push them synchronously, above the Shield script tag or the GTM container snippet:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
user_id_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
plan: 'pro',
lead_source: 'google_ads'
});
</script>Browser-hashed, npm. Fill the identify object from your app's current user, hash with WebCrypto, push, then initialize:
import { init } from '@findip/shield';
// Identify the logged-in visitor (optional).
// Fill these from your app's current user. Values are hashed in the browser;
// Shield never receives the raw user ID or email address.
const identify = {
userId: '', // e.g. currentUser.id
email: '', // e.g. currentUser.email
plan: '', // e.g. currentUser.plan
salt: '' // optional secret; use the same salt when resolving hashes
};
async function sha256(text: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
}
async function identifyVisitor(): Promise<void> {
const prefix = identify.salt ? identify.salt + ':' : '';
const email = identify.email.trim().toLowerCase();
const context: Record<string, string> = {};
if (identify.plan) context.plan = identify.plan;
if (email.includes('@')) {
context.email_domain = email.split('@').pop() ?? '';
context.email_hash = await sha256(prefix + email);
}
if (identify.userId) context.user_id_hash = await sha256(prefix + identify.userId.trim());
if (Object.keys(context).length) {
// The SDK reads these keys from the dataLayer for every automatic event.
const w = window as unknown as { dataLayer?: object[] };
(w.dataLayer ??= []).push(context);
}
}
await identifyVisitor();
init({
siteKey: 'pub_xxxxxxxxx',
privacyMode: 'balanced',
autoTrack: true,
autoDetectForms: true,
});For a plain script tag, the SDK must be loaded after the hashes are ready, because hashing is asynchronous. The Install page's script-tag snippet with the toggle enabled does exactly that: it hashes, pushes to the dataLayer, then injects v1.js (or the pinned SRI build) and calls FindIP.init().
Every automatic event (session_start, page_view, and the automatic form events) then carries those values. Two details matter:
- The SDK takes the first value it finds for each key across all dataLayer entries. Push once, early. A later push with the same key does not replace the earlier one.
- Only these keys are read from the dataLayer:
user_id_hash,email_hash,email_domain,plan,transaction_amount,currency,form_name,lead_source. Thecustomobject andaccount_age_daysare accepted only throughFindIP.track().
3. FindIP.track()
JavaScript SDK, or a GTM Custom HTML tag.
Manual events accept the full field list, including custom:
FindIP.track('checkout_started', {
user_id_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
account_age_days: 412,
plan: 'pro',
transaction_amount: 129.0,
currency: 'USD',
custom: { cart_items: 3, coupon_applied: true, region: 'eu-west' }
});Context passed to track() applies to that event only. Manual events do not read the dataLayer, so pass the identifier on each call.
4. WordPress, WooCommerce, and Shopify
The official plugins send coarse page-type context only (product view, checkout view, order received) and never user identifiers. To identify users on those platforms, use the GTM template fields from option 1 if you also run GTM, add the dataLayer push from option 2 to your theme template for logged-in users, or call FindIP.track() from your own code.
Field reference
Any field not in this table is dropped.
| Field | Type | Accepted values | Automatic events (dataLayer) | FindIP.track() |
|---|---|---|---|---|
user_id_hash | string | hash-shaped, see above | yes | yes |
email_hash | string | hash-shaped, see above | yes | yes |
email_domain | string | domain only, e.g. gmail.com, no @ | yes | yes |
plan | string | up to 256 characters | yes | yes |
lead_source | string | up to 256 characters | yes | yes |
form_name | string | up to 256 characters | yes | yes |
transaction_amount | number | any finite number | yes | yes |
currency | string | ISO 4217 code, uppercase, e.g. USD | yes | yes |
account_age_days | number | any finite number | no | yes |
custom | object | up to 20 keys, key names up to 64 characters, values are strings up to 256 characters, numbers, or booleans | no | yes |
Inside custom, keys whose names contain password, card, cvv, ssn, secret, and similar are dropped, and any string value that looks like an email address, phone number, or card number is dropped, wherever it appears.
Where the context appears
Open the site in the Shield dashboard, go to Events, and click an event. The detail drawer shows the stored context under Raw payload (sanitized) as customer_context. Fields that were dropped are shown as null, which is the quickest way to check that a value passed validation.
Troubleshooting
user_id_hash is null in the payloadThe value was not hash-shaped. Send the hex or base64 digest, not the raw ID.custom value is missingIt was longer than 256 characters, matched a sensitive pattern, or the object already had 20 keys.debug: true to the init options (or data-debug="true" on the script tag) to log each outgoing event to the browser console.