Identity signing
Prove to MailEditor which of your users is opening the editor, with an HMAC signature computed on your server.
Templates and images inside the editor are scoped to external_user_id. Anyone who can set that value can open another user's work, so it must come from your server, which signs the id with the Plugin Secret before the page renders the iframe.
The signature
Build this string, using the length of the user id in bytes:
v1|{byte length of external_user_id}:{external_user_id}|{identity_expires}Sign it with HMAC-SHA256 using the Plugin Secret as the key, hex encoded. Pass the user id, the expiry and the signature to the iframe as external_user_id, identity_expires and identity_signature.
identity_expires is a Unix timestamp in seconds, at most 15 minutes in the future. Five minutes is a sensible default.
Test vector
| Plugin Secret | 8f2b91c0d4e64a7fa3c5b1d7e9028461 |
external_user_id | customer-user-4471 |
identity_expires | 1756280000 |
| Payload | `v1 |
| Signature | 902ddd4e1a5c5987ce06e0c4dace6a841755a6a185b57d6fbe8631575cffe933 |
Code
import { createHmac } from 'node:crypto'
const expires = Math.floor(Date.now() / 1000) + 300
const payload = `v1|${Buffer.byteLength(userId)}:${userId}|${expires}`
const signature = createHmac('sha256', pluginSecret).update(payload).digest('hex')$expires = time() + 300;
$payload = sprintf('v1|%d:%s|%d', strlen($userId), $userId, $expires);
$signature = hash_hmac('sha256', $payload, $pluginSecret);import hashlib, hmac, time
expires = int(time.time()) + 300
payload = f"v1|{len(user_id.encode())}:{user_id}|{expires}"
signature = hmac.new(plugin_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()require 'openssl'
expires = Time.now.to_i + 300
payload = "v1|#{user_id.bytesize}:#{user_id}|#{expires}"
signature = OpenSSL::HMAC.hexdigest('SHA256', plugin_secret, payload)A complete Next.js route that returns these values to the page is in the quickstart. Full sign-and-embed examples for other stacks are in the Platform guides.
Where to sign
Sign on the request that renders the page containing the iframe, or in an API route the page calls right before mounting it. Do not sign at build time or cache the result.
The Plugin Secret stays in server-side configuration. Never put it in NEXT_PUBLIC_* variables, client bundles or HTML.
Identity modes
| Mode | Iframe URL | Use |
|---|---|---|
signed | Must carry all three identity parameters. | Default. Use it in production. |
open | external_user_id only. The editor trusts what the page says. | Prototyping on a page real users cannot reach. |
backend | The editor cannot be opened from a browser. Only your server can create sessions, by sending the secret to the session endpoint. The Backend API keeps working. | Server-only integrations. |
Set the mode under End user identity on the General Information tab. Changing it ends every live editor session for the plugin.
Common mistakes
| Symptom | Cause |
|---|---|
identity_signature_invalid for ids with accents or emoji | Length counted in characters instead of bytes. |
identity_signature_invalid on every request | Wrong secret, for example another plugin's or a stray space copied with it. |
identity_signature_expired on slow pages | Expiry too short. Use a few minutes, not seconds. |
identity_expires_out_of_range | Expiry more than 15 minutes ahead, or in milliseconds instead of seconds. |
identity_signature_required | Plugin is in signed mode but the URL has no signature. |
external_user_id starts with anon: | That prefix is reserved and refused. |
Full details of the session request and its errors: session endpoint.

.png)