Implementing Cross-Device Identity & Consent
A step-by-step guide for developers to implement Concord's cross-device identity resolution, ensuring a user's consent choices follow them across every browser and device.
Overview
A single person often visits your site from multiple devices — a phone, a laptop, a tablet. Without cross-device identity, each device is treated as a separate visitor with its own consent state. Concord's Identity system solves this by linking anonymous browser sessions to a known user. When someone identifies themselves (for example, by logging in), their consent choices follow them across every device and project in your organization.
This guide walks you through the full integration, from generating authentication keys to calling identify() in your frontend code.
How Concord Identifies Users
Before diving into code, it helps to understand the three layers Concord uses to track visitors and their consent.
| Concept | What It Represents | Lifetime |
|---|---|---|
| Identity | The person | Permanent — survives across devices and sessions |
| Context | A browser or device | Created once per browser, never changes |
| Session | A single visit | Short-lived, used for API authentication |
Here's how they relate:
One Person (Identity)
└── Many Devices (Context)
└── Many Visits (Session)- One identity can have many contexts — the same person on their laptop and phone.
- One context can have many sessions — multiple visits from the same browser over time.
- Consent is stored on the identity, so it follows the person everywhere.
What Happens When a Visitor Arrives
When someone first visits your site, Concord automatically creates all three: a new session for this visit, a new context for this browser, and a new anonymous identity. At this point the visitor is anonymous, but their consent choices are already tied to their identity.
What Happens During identify()
When the visitor identifies themselves (for example, after login), Concord links their anonymous identity to a known one:
- First device: The visitor's anonymous identity gains an email (or userId) alias. Nothing else changes —
synced: false. - Second device: A different anonymous identity exists on this browser. When the visitor identifies with the same email, Concord finds the existing identity from the first device, merges the two, and syncs consent —
synced: true.
After a merge, the browser's context stays the same — only which identity it belongs to is updated. The visitor's consent from the first device is now available on the second.
Prerequisites
Before beginning the integration, ensure the following:
- Cross-device consent enabled in your Concord Organization settings (Advanced Settings > Cross-Device Consent).
- Concord site-client snippet installed and initialized on your site.
- An asymmetric key pair for JWT authentication (required for email and userId flows — not needed for contextId).
Step 1: Set Up Authentication Keys
Concord uses JWT authentication to verify identity requests. Your server signs a short-lived token with a private key; Concord verifies it with the corresponding public key you upload.
Generate an EC P-256 Key Pair
# Generate EC private key
openssl ecparam -genkey -name prime256v1 -noout -out identify-key.pem
# Extract public key
openssl ec -in identify-key.pem -pubout -out identify-key-pub.pemConvert to JWK Format
Concord accepts public keys in JWK format. You can convert your PEM keys using a tool like jwt.io or with the Node.js scripts below.
Private key JWK (keep this secret — used on your server to sign tokens):
node -e "
const crypto = require('crypto');
const fs = require('fs');
const priv = crypto.createPrivateKey(fs.readFileSync('identify-key.pem'));
const jwk = priv.export({ format: 'jwk' });
jwk.kid = 'my-key-1';
jwk.alg = 'ES256';
jwk.use = 'sig';
console.log(JSON.stringify(jwk, null, 2));
" > identify-private.jwkPublic key JWK (upload this to Concord):
node -e "
const crypto = require('crypto');
const fs = require('fs');
const pub = crypto.createPublicKey(fs.readFileSync('identify-key-pub.pem'));
const jwk = pub.export({ format: 'jwk' });
jwk.kid = 'my-key-1';
jwk.alg = 'ES256';
jwk.use = 'sig';
console.log(JSON.stringify(jwk, null, 2));
" > identify-public.jwkUpload the Public Key
Upload your public key via the Concord admin UI under Integrations > Public Keys. The kid value you set here (e.g., my-key-1) must match the kid in the JWT header your server generates.
Step 2: Generate Tokens on Your Server
Your server must generate a JWT for each identify call. The token proves the request is legitimate and prevents impersonation.
Required JWT Fields
| Field | Location | Required | Description |
|---|---|---|---|
alg | Header | Yes | Algorithm — e.g., ES256 for EC P-256 |
kid | Header | Yes | Must match the kid of an uploaded public key |
sub | Payload | Yes | Must exactly match the value passed to identify() |
exp | Payload | Yes | Expiration timestamp (maximum 10 minutes from now) |
aud | Payload | Optional | If present, must match your Organization ID |
Option A: Node.js crypto (Zero Dependencies)
const crypto = require('crypto');
const fs = require('fs');
const privateKey = crypto.createPrivateKey(fs.readFileSync('identify-key.pem'));
function generateIdentifyToken(identifyValue) {
const b64url = (obj) =>
Buffer.from(JSON.stringify(obj)).toString('base64url');
const header = { alg: 'ES256', kid: 'my-key-1' };
const payload = {
sub: identifyValue,
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
};
const data = b64url(header) + '.' + b64url(payload);
const sig = crypto
.sign('SHA256', Buffer.from(data), privateKey)
.toString('base64url');
return data + '.' + sig;
}Option B: Using jsonwebtoken
npm install jsonwebtokenconst jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('identify-key.pem');
function generateIdentifyToken(identifyValue) {
return jwt.sign({ sub: identifyValue }, privateKey, {
algorithm: 'ES256',
expiresIn: '5m',
keyid: 'my-key-1',
});
}Deliver the Token to the Browser
Pass the token to your frontend via an API endpoint, a page template variable, or any secure channel. The token is short-lived and scoped to a single identity value.
// Example: API endpoint (behind your existing auth)
app.get('/api/identify-token', authenticate, (req, res) => {
const { email } = req.user; // from your auth/session
const token = generateIdentifyToken(email);
res.json({ token, email });
});On the client side, fetch the token and pass both values to identify():
const { token, email } = await fetch('/api/identify-token').then((r) =>
r.json(),
);
await window.concord.identify(email, { token });Step 3: Identify Users in the Browser
There are several integration patterns depending on when and how you know the user's identity.
Pattern A: After Login (Most Common)
Call identify() after the user logs in or when your app confirms their identity. The first argument is the identifier value (such as an email address), and the token option is the JWT from your server.
async function onUserLogin(email, jwtToken) {
const result = await window.concord.identify(email, {
token: jwtToken,
});
if (result.synced) {
// Consent was synced from another device.
// The SDK has already refreshed consent data — no action needed.
console.log('Consent synced from another device');
} else {
// First time identifying with this email, or same identity.
// No merge happened — consent banner will show as normal.
console.log('Identity registered');
}
}Pattern B: Server-Rendered Pages (Pre-Init)
For pages where the user's identity is known before the Concord snippet loads (for example, server-rendered authenticated pages), set window.concordIdentity before the SDK initializes. This resolves identity during initialization, before the consent banner renders — preventing a "flash of consent banner" for returning users.
<script>
// Set BEFORE the Concord snippet loads
window.concordIdentity = {
type: 'email',
value: '<%= user.email %>', // server-rendered
token: '<%= identifyToken %>', // JWT from your server
};
</script>
<!-- Concord snippet -->
<script src="..."></script>Pattern C: Customer ID (userId)
If you identify users by an internal customer ID rather than email:
const result = await window.concord.identify('customer-12345', {
type: 'userId',
token: jwtToken, // JWT sub must be 'customer-12345'
});The userId is stored as-is (not hashed) and is scoped to your organization. The same userId will match across all projects within your organization.
Pattern D: ContextId (No JWT Needed)
If you have a contextId from another session (for example, from a cross-domain flow), you can use it to link two sessions without JWT authentication:
const result = await window.concord.identify(otherContextId, {
type: 'contextId',
});Or via pre-init:
window.concordIdentity = {
type: 'contextId',
value: otherContextId,
};Why no JWT? A contextId is a cryptographically random UUID — it is not personally identifiable. Concord validates that the referenced context exists before merging.
Step 4: Handle the Result
Return Values
| Field | Type | Description |
|---|---|---|
synced | boolean | true if consent was synced from another device; false if this is the first identification or the identity already matches |
error | Error (optional) | Present if something went wrong |
When synced is true, the SDK automatically:
- Updates the session token
- Refreshes consent data from the server
- Dispatches a
concord-identity-syncedevent
You do not need to manually refresh consent or update the session.
Event Listeners
// React to identity sync
window.addEventListener('concord-identity-synced', (event) => {
console.log('Identity synced:', event.detail.synced);
});
// React to identity reset (logout)
window.addEventListener('concord-identity-reset', () => {
console.log('Identity reset to anonymous');
});Step 5: Handle Logout
When a user logs out, call reset() to clear the session and create a new anonymous identity. This ensures the next visitor on a shared device starts fresh and does not inherit the previous user's consent.
async function onUserLogout() {
await window.concord.reset();
// Session cleared, new anonymous identity created.
// The consent banner will reappear on the next page load.
}Advanced: Pre-Hashed Emails
By default, Concord hashes emails server-side using a per-organization salt. If you prefer to hash emails on your own server before sending them to the browser (to avoid exposing plaintext emails in client-side code), use the format: 'hashed' option:
const result = await window.concord.identify(hashedEmail, {
type: 'email',
format: 'hashed',
token: jwtToken, // JWT sub must match the hashed value
});When using format: 'hashed':
- The value is used as-is (Concord does not hash it again)
- No email format validation is performed
- The JWT
subclaim must match the hashed value, not the original email
Configuration Reference
identify() Parameters
window.concord.identify(value, options);| Parameter | Type | Required | Description |
|---|---|---|---|
value | string | Yes | The identifier — an email, userId, or contextId |
options.type | string | No | 'email' (default), 'userId', or 'contextId' |
options.format | string | No | 'plain' (default) or 'hashed' — only applies to email type |
options.token | string | Conditional | JWT signed by your server. Required for email and userId. Not needed for contextId. |
window.concordIdentity (Pre-Init)
| Property | Type | Required | Description |
|---|---|---|---|
type | string | Yes | 'email', 'userId', or 'contextId' |
value | string | Yes | The identifier value |
format | string | No | 'plain' (default) or 'hashed' — only applies to email type |
token | string | Conditional | JWT signed by your server. Required for email and userId. Not needed for contextId. |
Frequently Asked Questions
-
I'm getting a
403 Forbiddenerror on/identify. Cross-device consent is not enabled for your organization. Enable it in the admin UI under Advanced Settings > Cross-Device Consent, or contact your Concord administrator. -
I'm getting a
401 Unauthorizederror on/identify. The JWT is missing, expired, or invalid. Check that:- The
tokenoption is included in youridentify()call (required for email and userId) - The JWT
subclaim matches thevalueyou passed toidentify()exactly - The JWT
kidmatches a public key uploaded to your organization - The JWT
expis in the future (maximum 10 minutes from now) - The algorithm is asymmetric (ES256, RS256, etc.) — HMAC algorithms are not accepted
- The
-
identify()returnssynced: false— is something wrong? Not necessarily.synced: falseis expected in these cases:- First identification: The first time Concord sees an email or userId, no match exists yet. The alias is registered for future matches.
- Same identity: If the current session already belongs to the matched identity, no merge is needed.
- Different organization: Alias lookups are scoped to your organization. Sessions from different organizations will not match.
-
The consent banner still shows after calling
identify().- Verify that
identify()returnedsynced: truewith no error. - The SDK automatically refreshes consent data after a successful sync. If the banner persists, check that consent was actually recorded on the target identity (the one that was identified first on another device).
- Ensure the consent types in the target identity's project match the active Region Template for your current project.
- Verify that
-
My pre-init identity (
window.concordIdentity) isn't working.- It must be set before the Concord snippet loads.
- The value must be an object with at least
typeandvalueproperties. - The property is consumed once during initialization — it cannot be reused on the same page.
-
Does the contextId flow require a JWT? No. Since a contextId is a cryptographically random UUID (not personally identifiable), it does not require JWT authentication. This makes it ideal for cross-domain syncing where you may not have a server-side token endpoint on every domain.
-
What happens to consent when a user logs out? When you call
reset(), the session is cleared and the browser returns to an anonymous state. The next visitor on that device will not inherit the previous user's consent — they will start fresh with a new anonymous identity.
Configuring IAB TCF v2.3
How to enable and configure IAB Transparency and Consent Framework (TCF) v2.3 in Concord for GDPR-compliant advertising consent, including vendor management, stacks, legitimate interest, and privacy center integration.
Manually Blocking Trackers Before Consent
Learn how to manually block a tracker when needed using Concord