Security Settings
Unlayer allows developers to enforce Identity Verification to enhance the security of the embedded builder. By verifying the identity of the users interacting with the builder, you can prevent unauthorized third parties from impersonating your logged-in users and misusing the builder. This feature is especially important for ensuring that only authenticated users have access to the builder.
We strongly recommend that you enforce identity verification to protect your application and users.
Prerequisites
In order to enable enhanced security and identity verification, you must set up the following first:
Setup Identity Verification
To set up identity verification, you’ll need to generate a secure HMAC (Hash-based Message Authentication Code) signature on your server for each logged-in user. This HMAC signature is then passed to Unlayer along with the user’s information when initializing the editor.
Sending User Object with HMAC Signature
When initializing the Unlayer builder, you need to pass a user object that includes:
- A unique id for the logged-in user
- A securely generated signature (HMAC)
- Optional information such as name and email for the user
Here’s an example of how to initialize the builder with a user object:
unlayer.init({
user: {
id: 1, // The user's unique ID
signature: 'XXX', // HMAC signature generated on the server
name: 'John Doe', // Optional user name
email: 'john.doe@acme.com', // Optional user email
},
});
Generating the HMAC Signature
To ensure security, the HMAC signature must be generated on your server using a secret key. The secret key must never be exposed in client-side code or shared publicly. Here’s how to generate the signature using Node.js and the crypto module:
Your Project Secret is a sensitive piece of information that should never be exposed in your client-side code, repositories, or any place where it could be accessed by third parties. Always store it securely on your server and limit access to it.
const crypto = require('crypto');
const signature = crypto
.createHmac('sha256', '[PROJECT-SECRET]') // secret key (keep safe!)
.update('[USER-ID]')
.digest('hex');
require 'openssl'
OpenSSL::HMAC.hexdigest(
'sha256', # hash function
'[PROJECT-SECRET]', # secret key (keep safe!)
'[USER-ID]' # user's id
)
hash_hmac(
'sha256', // hash function
$user->id, // user's id
'[PROJECT-SECRET]' // secret key (keep safe!)
);
import hmac
import hashlib
hmac.new(
b'[PROJECT-SECRET]', # secret key (keep safe!)
bytes(request.user.id, encoding='utf-8'), # user's id
digestmod=hashlib.sha256 # hash function
).hexdigest()
You can get your project secret from project settings in the Unlayer console.
Explanation
- [PROJECT-SECRET]: This is your project-specific secret key. It must be kept secure and never exposed to the public or in your client-side code.
- [USER-ID]: This is the unique ID for the user (for example, id: 1 in the user object).
- The resulting HMAC signature is a cryptographic hash that verifies the identity of the user when passed to Unlayer during initialization.
Why Use Identity Verification?
- Prevent Impersonation: Without identity verification, third parties could potentially impersonate your logged-in users and access or modify content in the editor. By requiring a valid HMAC signature, you ensure that only authorized users can interact with the Unlayer editor.
- Enhanced Security: This feature ensures that your users’ actions in the builder are authenticated and protected from potential tampering.
Example Workflow
- User logs in to your application.
- On the server, you generate an HMAC signature using the user’s unique ID and your secret key.
- Initialize the Unlayer builder with the user object containing the user’s ID and the generated HMAC signature.
- Unlayer verifies the signature and ensures that the user accessing the editor is authenticated.
Notes
- Do not commit your secret key to any public repositories or client-side code.
- Always generate the HMAC signature server-side to prevent exposure of your secret key.
- Identity verification only affects users interacting with the Unlayer builder within your application, ensuring they are properly authenticated.
Restricting Features Per End-User
unlayer.init({ features }) configures the editor for everyone who loads it. A signed feature
policy does something different: it sets what one specific end-user is permitted to do. You
sign it with your Project Secret, so the permission belongs to that end-user and travels with them.
Only when different end-users of the same project should have different features — for example
full template generation for your Pro tier but not your Free tier. To configure a feature for
everyone who loads the editor, use unlayer.init({ features }); to remove one from your workspace
altogether, ask your account manager.
Building the Token
The token uses the same Project Secret as identity verification, but derives a policy-only signing key from it with HKDF-SHA256. It has three parts joined by dots: the v1 format version, a base64url-encoded payload, and the HMAC-SHA256 of the version and encoded payload, written as lowercase hexadecimal. The signature comparison is case-sensitive, so uppercase hex or a base64 digest fails verification.
const { createHmac, hkdfSync } = require('node:crypto');
const projectSecret = '[PROJECT-SECRET]';
const version = 'v1';
const salt = 'unlayer:end-user-policy-token:v1:salt';
const info = 'unlayer:end-user-policy-token:v1:signing-key';
const claims = {
id: String(user.id), // must match the id you pass to unlayer.init
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 8, // unix seconds
features: {
ai: {
fullTemplateGeneration: user.plan === 'pro',
},
},
};
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
const policyKey = Buffer.from(
hkdfSync('sha256', projectSecret, salt, info, 32),
);
const signedPayload = `${version}.${payload}`;
const signature = createHmac('sha256', policyKey)
.update(signedPayload)
.digest('hex');
const token = `${signedPayload}.${signature}`;
require 'base64'
require 'json'
require 'openssl'
claims = {
id: user.id.to_s,
exp: Time.now.to_i + 60 * 60 * 8,
features: { ai: { fullTemplateGeneration: user.plan == 'pro' } }
}
project_secret = '[PROJECT-SECRET]'
version = 'v1'
salt = 'unlayer:end-user-policy-token:v1:salt'
info = 'unlayer:end-user-policy-token:v1:signing-key'
payload = Base64.urlsafe_encode64(claims.to_json, padding: false)
# HKDF-SHA256 extract, then the single expand block needed for a 32-byte key.
prk = OpenSSL::HMAC.digest('SHA256', salt, project_secret)
policy_key = OpenSSL::HMAC.digest('SHA256', prk, info + "\x01")
signed_payload = "#{version}.#{payload}"
signature = OpenSSL::HMAC.hexdigest('sha256', policy_key, signed_payload)
token = "#{signed_payload}.#{signature}"
$claims = [
'id' => (string) $user->id,
'exp' => time() + 60 * 60 * 8,
// PHP encodes an empty array as a JSON array, which is rejected. Use
// new stdClass() for an empty `features` object or an empty nested group.
'features' => ['ai' => ['fullTemplateGeneration' => $user->plan === 'pro']],
];
$projectSecret = '[PROJECT-SECRET]';
$version = 'v1';
$salt = 'unlayer:end-user-policy-token:v1:salt';
$info = 'unlayer:end-user-policy-token:v1:signing-key';
$payload = rtrim(strtr(base64_encode(json_encode($claims)), '+/', '-_'), '=');
// HKDF-SHA256 extract, then the single expand block needed for a 32-byte key.
$prk = hash_hmac('sha256', $projectSecret, $salt, true);
$policyKey = hash_hmac('sha256', $info . "\x01", $prk, true);
$signedPayload = $version . '.' . $payload;
$signature = hash_hmac('sha256', $signedPayload, $policyKey);
$token = $signedPayload . '.' . $signature;
import base64, hashlib, hmac, json, time
claims = {
'id': str(user.id),
'exp': int(time.time()) + 60 * 60 * 8,
'features': {'ai': {'fullTemplateGeneration': user.plan == 'pro'}},
}
project_secret = b'[PROJECT-SECRET]'
version = 'v1'
salt = b'unlayer:end-user-policy-token:v1:salt'
info = b'unlayer:end-user-policy-token:v1:signing-key'
payload = base64.urlsafe_b64encode(
json.dumps(claims).encode()
).rstrip(b'=').decode()
# HKDF-SHA256 extract, then the single expand block needed for a 32-byte key.
prk = hmac.new(salt, project_secret, hashlib.sha256).digest()
policy_key = hmac.new(prk, info + b'\x01', hashlib.sha256).digest()
signed_payload = f'{version}.{payload}'
signature = hmac.new(policy_key, signed_payload.encode(), hashlib.sha256).hexdigest()
token = f'{signed_payload}.{signature}'
Use the exact HKDF salt and info above, then compute the HMAC over v1.<base64url-payload>. Do not sign with the Project Secret directly or rebuild the JSON to sign — direct signing can confuse this credential with an identity signature, while re-encoding can change key order and number formatting between languages.
Passing the Token
Send the generated token to the browser and pass it in the user object. It replaces signature:
unlayer.init({
projectId: 1234,
user: {
id: 1, // must match the id inside the token
name: 'John Doe',
email: 'john.doe@acme.com',
featurePolicyToken: '[GENERATED-TOKEN]',
},
});
The editor reflects the permissions in the token, and Unlayer honours them for that end-user.
Omitting user.featurePolicyToken or setting it to null means no policy token was supplied. This
supports integrations that serialize an unavailable token as null; the editor then uses the normal
legacy-signature or unsigned flow. An empty string is not missing: like any other malformed,
expired, wrongly signed, or mismatched non-null value, it fails authentication even when Identity
Verification is off. Projects that require end-user feature policies also reject null.
A policy applies to a project whatever editor version it runs, but the editor's own handling of it — hiding the features an end-user is not permitted to use, and recognising the most recently added paths — needs a current build. If you pin your editor version, move to a current release before relying on a policy.
What features Accepts
The features object uses the boolean feature-flag portion of the shape you pass to unlayer.init, so you write a restriction once, in one vocabulary. It is required, although it may be an empty object when the token is being introduced before restrictions are assigned. It must encode as a JSON object: in PHP, json_encode([]) produces [], so pass new stdClass() instead.
Every leaf must be true or false; objects are containers for nested flags. Configuration values, arrays, null, duplicate or dotted keys, unsafe object keys, and nesting deeper than eight levels make authentication fail instead of being ignored. Include only feature flags in the signed object — do not copy configuration such as ai.model or textEditor.customButtons into it.
These paths are enforced by the server:
| Path | Effect when false |
|---|---|
ai.assistant | This end-user cannot use the builder's AI Assistant or the standalone Image Editor's AI Assistant. |
ai.fullTemplateGeneration | This end-user cannot generate full templates, pages, or bodies. Row, content, and text edits still work. |
ai.magicImage | This end-user cannot generate, edit, or read recent Magic Images, and cannot use the Image Editor's AI Assistant. |
ai.smartButtons | This end-user cannot generate Smart Button suggestions. |
ai.smartHeadings | This end-user cannot generate Smart Heading suggestions. |
ai.smartImageAltText | This end-user cannot generate image alt text. |
ai.smartParagraph | This end-user cannot generate Smart Paragraph suggestions. |
ai.smartText | This end-user cannot generate Smart Text suggestions. |
inboxPreviews | This end-user cannot generate inbox previews. |
sendTestEmail | This end-user cannot send test emails. |
stockImages | This end-user cannot search the stock image library. Their own uploads are unaffected. |
userUploads | This end-user cannot upload images. Stock image search is unaffected. |
Any other flag you set to false is turned off by the editor for this end-user, on top of what unlayer.init configures; only the paths above are also enforced by Unlayer's servers. A true value never turns anything on — it only means the policy does not restrict that flag, and unlayer.init still decides. The editor applies a signed false after unlayer.init, so sign only flags where false means "off": for a flag where false is the looser setting, such as stockImages.safeSearch, a signed false overrides the true you pass to unlayer.init.
Each path stands on its own — apart from the two shorthands below, denying one never implies
another. In particular, ai.smartText: false leaves Smart Paragraph permitted.
Smart Text and Smart Paragraph run the same text generation, and the server checks whichever path
matches the surface a request names. Denying only one of them hides that surface in the editor, but
the end-user can still get the same generation through the other. To stop an end-user from
generating that text, deny both ai.smartText and ai.smartParagraph.
Two shorthands disable everything beneath them, matching how unlayer.init behaves:
features: {
ai: false,
} // same as disabling every ai.* path
features: {
ai: {
enabled: false,
},
} // same
Rules to Keep in Mind
- A policy can only restrict. It never grants a feature your plan does not include. If your subscription lacks the AI Assistant, setting
ai.assistant: truechanges nothing. unlayer.initcan restrict further, never less. If the token allows a feature andunlayer.initdisables it, it stays hidden.- The
idin the token must matchuser.id. A mismatch fails verification. - Generate the token on your server. The Project Secret must never reach the browser.
Choosing an Expiry
exp is required, in unix seconds. The editor session expires with it, so set it to at least the length of a working session in your application — typically several hours. A short expiry (minutes) will interrupt users mid-edit, because the editor has no way to fetch a fresh token on its own; the page must be reloaded with a newly generated one.
Migrating From signature
By default, featurePolicyToken and signature are both accepted so you can roll the token out gradually without breaking anyone. This compatibility mode is for migration only: a retained signature has no expiry or feature restrictions and can be presented without the policy.
Finish the migration in this order:
- Every end-user receives a
user.featurePolicyToken. - Your app no longer sends
signature. When a request carries both, the feature policy token is what authenticates — but a signature carries no policy, so while your app still issues one it can be presented on its own instead. - In Security Settings, enable Require End-User Feature Policies. Enabling it also enables Identity Verification. From that point on, authentication fails when
user.featurePolicyTokenis missing or when only a legacy signature is supplied.
Use a signed token with features: {} as the positive control before enabling the requirement. It proves every active integration can create and exchange policy-backed sessions without changing feature access. Once required mode is enabled, it is enforced by the editor bootstrap and by every editor API request, including requests made with sessions issued before the setting changed. Those sessions stop working on their next request, so an end-user who was still on a legacy signature must reload the editor with a featurePolicyToken.
Signatures do not expire. Required mode rejects them without requiring a secret rotation. If you also want to invalidate old signatures and policy tokens cryptographically, reset the Identity Verification Secret in the console. That invalidates all credentials signed with the old secret at once, so coordinate it with a deploy where every end-user already receives a new featurePolicyToken.
Rotating the secret prevents old signatures and policy tokens from creating new bootstrap credentials. A bootstrap credential that was already issued can still be exchanged for a session until its original expiry (at most seven days from authentication), and the resulting session remains usable until that same limit. Refreshing session data never extends the limit.
New sessions retain server-issued proof that their original identity credential was verified, so operations such as Send Test Email trust the session until it expires instead of rechecking a credential against the rotated secret. Sessions issued before that proof was recorded can still fail those operations; reload them with a newly signed credential after rotation. Plan the rotation window accordingly.