Identity signing
RSMG Engage never sees your readers' credentials. Instead, you vouch for who is acting by attaching a cryptographic proof to each write. This is the single most important integration detail to get right, so this page is deliberately pedantic about it.
There are two ways to identify the acting reader, chosen per request by which proof you attach:
- HMAC identity assertion — your backend holds a per-tenant secret and signs each request. Use this when you have a server in the path. It's this page's main subject.
- OIDC ID token — your client already holds a JWT from its identity provider (Firebase, Cognito, Auth0, Okta, Entra) and sends it directly; RSMG Engage verifies it against the provider's public keys. Use this for a no-backend mobile app or SPA — see OIDC ID tokens below.
A tenant can have both configured at once. Attach exactly one per request — sending both is
rejected 400.
Threat model
The assertion signs identity, not the request body. Within its freshness window (~1 hour by default) it is therefore a bearer credential: anyone holding it can replay it on a different request as that user.
That is safe in exactly one shape — server-to-server (Model A), where your backend holds the signing secret, mints the assertion, and sends it directly to RSMG Engage.
:::danger Never send the secret (or a pre-signed assertion) to a client Do not embed the signing secret in a browser bundle, mobile app, or SPA. A mobile/SPA client must either call your backend (which signs and forwards the request) or use OIDC ID tokens instead — never the HMAC secret. A leaked identity secret forges every user of your tenant. :::
How it works
- A reader, already logged in on your site, takes an action (posts a comment, reacts, votes).
- Your backend builds an assertion describing that reader and signs it with your tenant's secret.
- Your backend calls RSMG Engage with the API key and the two identity headers.
- RSMG Engage verifies the signature, extracts the reader's
external_id, upserts the user, and attributes the action to them.
Verify mode is mandatory in deployed environments. A deployed tenant with no signing secret
fails closed: acting writes return 403 IDENTITY_VERIFICATION_REQUIRED rather than trusting an
unsigned user_id. (Local development offers a "trust mode" that reads body.user_id directly; it
is never available in production.)
Get your signing secret
Mint it in the admin console (Console → Identity → Mint). The plaintext secret (64 hex characters) is shown once, so store it on your backend immediately. Minting flips your tenant from trust mode into verify mode.
The assertion
A JSON object, base64url-encoded (no padding):
{ "external_id": "user-42", "display_name": "Ada Lovelace" }
external_id(required) — your opaque identifier for the reader. Becomes the acting user.display_name(optional) — updates the stored name when present; left unchanged when omitted.
This is sent in the X-RSMG-Engage-Identity header.
The signature — canonical string
The X-RSMG-Engage-Identity-Signature header has three comma-separated parts:
X-RSMG-Engage-Identity-Signature: t=<unix-seconds>,v1=<hex>,kid=<8-hex>
Compute them as follows. Read each line carefully: the most common mistake is signing the wrong bytes.
t = current time, in integer UNIX SECONDS (not milliseconds)
v1 = hex( HMAC_SHA256( secret, t + "." + assertion ) )
kid = hex( SHA256( secret ) )[0:8]
- The HMAC input is
t, a literal., then the exact base64url assertion string from theX-RSMG-Engage-Identityheader — not the decoded JSON, and not a re-serialized copy. kidis an 8-char fingerprint of the secret that tells the server which secret to verify against (it matters during rotation).
Worked test vector
Reproduce this exactly before you call the API: if your v1 matches, your implementation is correct.
These values come from the Node snippet below with a fixed t.
secret = 4f3c2b1a09e8d7c6b5a4938271605f4e3d2c1b0a99887766554433221100ffee
payload = {"external_id":"user-42","display_name":"Ada Lovelace"}
t = 1733740800
assertion (X-RSMG-Engage-Identity):
eyJleHRlcm5hbF9pZCI6InVzZXItNDIiLCJkaXNwbGF5X25hbWUiOiJBZGEgTG92ZWxhY2UifQ
kid = 0c38f814
v1 = 7f4b1eeaaee70744089618cb2bdc8a4246ec25ee2d4ce1aa4b08258635585489
X-RSMG-Engage-Identity-Signature:
t=1733740800,v1=7f4b1eeaaee70744089618cb2bdc8a4246ec25ee2d4ce1aa4b08258635585489,kid=0c38f814
Code
Each snippet builds the two headers from a payload and your secret. In production, t is the current
time; to reproduce the test vector above, hard-code t = 1733740800.
- PHP
- Node.js
- Python
<?php
function rsmg_sign_identity(array $payload, string $secret): array {
// Compact JSON, unescaped — the exact bytes we sign are the bytes we send.
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$assertion = rtrim(strtr(base64_encode($json), '+/', '-_'), '='); // base64url, no padding
$t = time();
$v1 = hash_hmac('sha256', "$t.$assertion", $secret);
$kid = substr(hash('sha256', $secret), 0, 8);
return [
'X-RSMG-Engage-Identity' => $assertion,
'X-RSMG-Engage-Identity-Signature' => "t=$t,v1=$v1,kid=$kid",
];
}
// $headers = rsmg_sign_identity(['external_id' => 'user-42', 'display_name' => 'Ada Lovelace'], $secret);
import { createHash, createHmac } from 'node:crypto';
function signIdentity(payload, secret) {
const assertion = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', secret).update(`${t}.${assertion}`).digest('hex');
const kid = createHash('sha256').update(secret).digest('hex').slice(0, 8);
return {
'X-RSMG-Engage-Identity': assertion,
'X-RSMG-Engage-Identity-Signature': `t=${t},v1=${v1},kid=${kid}`,
};
}
// const headers = signIdentity({ external_id: 'user-42', display_name: 'Ada Lovelace' }, secret);
import base64, hashlib, hmac, json, time
def sign_identity(payload: dict, secret: str) -> dict:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") # compact JSON
assertion = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") # base64url, no padding
t = int(time.time())
v1 = hmac.new(secret.encode(), f"{t}.{assertion}".encode(), hashlib.sha256).hexdigest()
kid = hashlib.sha256(secret.encode()).hexdigest()[:8]
return {
"X-RSMG-Engage-Identity": assertion,
"X-RSMG-Engage-Identity-Signature": f"t={t},v1={v1},kid={kid}",
}
# headers = sign_identity({"external_id": "user-42", "display_name": "Ada Lovelace"}, secret)
Sending it
Attach both headers to any acting write — creating, editing, or deleting a comment; toggling a
reaction; casting a poll vote; or provisioning a user via PUT /users. In verify mode:
body.user_idis ignored — the acting user is the one in the assertion.- On reads, a
viewer_idquery param is ignored — pass the assertion to identify the viewer (for example, to compute "has this reader reacted?"). An invalid viewer assertion degrades gracefully: the read still succeeds, just without viewer enrichment.
Freshness, replay & clock skew
The assertion carries t; RSMG Engage rejects it if t is more than the freshness window
(default 3600 s, tunable per tenant) in the past (replay) or the future (clock skew).
- Mint a fresh assertion per request — don't cache and reuse one.
- Keep your servers on NTP.
- Re-read the threat model: the window is exactly why the assertion must never reach a client.
Key rotation
Rotating (Console → Identity → Rotate) issues a new secret and keeps the previous one valid
for a 24-hour overlap, so in-flight assertions signed with the old secret still verify. The
kid tells the server which secret to check. Roll your backend's secret within the overlap window;
no coordinated cutover is needed.
OIDC ID tokens
Everything above assumes a backend that holds a secret. For a client that can't safely hold one — a mobile app or browser SPA with no server of yours in the request path — use an OIDC ID token instead. The client sends the JWT it already gets from its identity provider (Firebase, Cognito, Auth0, Okta, Entra, Keycloak — anything issuing a standard OIDC ID token), and RSMG Engage verifies it against that provider's public keys. No secret lives on the client or in RSMG Engage.
Configure the issuer (one-time, per project)
An operator adds the issuer in the admin console (Console → Identity → OIDC issuers → Add) with:
- Issuer URL — e.g.
https://securetoken.google.com/your-firebase-project. RSMG Engage fetches its OpenID discovery document, derives the key (JWKS) URL, and test-fetches the keys before saving — a bad or unreachable URL fails with422and stores nothing. - Audience — the
audyour tokens are minted for (your Firebase project id, Cognito app-client id, …).
Optionally override which claim carries the user id (default sub) and which carries the display
name (e.g. name). Add one issuer per project/app — the issuer + audience pair is what scopes a
token to your tenant.
Send the token
Send the ID token as a standard bearer token — with no X-RSMG-Engage-Identity headers:
POST /api/v1/discussions/by-external/{externalId}/comments
X-API-Key: <your tenant api key>
Authorization: Bearer <the OIDC ID token>
Content-Type: application/json
{ "body": "Hello" }
X-API-Key authenticates your app; the bearer token identifies the reader. The two are
independent.
What RSMG Engage checks
- Signature against the issuer's published keys, with the algorithm pinned to RS256 / ES256
(
noneand HMACHS*are rejected). issandaudmatch a configured issuer exactly.- Expiry — OIDC tokens use their own
exp(with ~60 s clock-skew tolerance); the HMAC freshness window does not apply here. - The configured id claim (default
sub) is present; that value becomes the acting user, upserted on first action just like the HMAC path.
One reader across web and mobile
The user id is the raw claim value, not namespaced by provider. So if your web backend signs
HMAC assertions with external_id "user-42" and your mobile app's token carries sub "user-42",
both resolve to the same RSMG Engage user — one identity, comment on the app and see it on the
web. You own keeping that id consistent across the providers you enable; RSMG Engage upserts by id
and never merges two ids.
Threat model
An ID token is a smaller blast radius than the HMAC secret: it impersonates one reader until it expires, not every reader indefinitely — and it lets a no-backend client integrate at all. It is still a bearer credential within its lifetime, so keep token lifetimes short. (Request-binding / single-use nonces are not yet offered.)
Errors
| Status | Code | Meaning |
|---|---|---|
| 403 | IDENTITY_VERIFICATION_REQUIRED | The (deployed) tenant has no proof source configured, or you sent no proof. Mint a secret / add an OIDC issuer / attach the headers or token. |
| 401 | UNAUTHORIZED | A proof was sent but failed: bad HMAC signature, stale/future t, unknown kid, malformed assertion — or an invalid/expired OIDC token (bad signature, wrong aud/iss, untrusted issuer, wrong alg, missing id claim). |
| 400 | BAD_REQUEST | You attached both an HMAC assertion and a Bearer token — pick one. |
| 503 | IDENTITY_PROVIDER_UNAVAILABLE | (OIDC only) RSMG Engage couldn't reach your provider's keys right now — retry. Reads degrade to no enrichment instead of 503. |
The 403/401 split is deliberate so you can tell "not configured yet" from "fix your signing".
Debug checklist
Signature not matching? Work down this list:
- You signed the base64url assertion string, not the decoded JSON.
- The HMAC input is
t + "." + assertion— the right separator, no extra whitespace. -
tis in seconds, not milliseconds. - base64url uses
-/_and no=padding. -
kidis the first 8 hex chars ofsha256(secret). - Server and client clocks are within the freshness window.
- You're using the current secret (or one still inside its rotation overlap).
- Reproduce the test vector with
t = 1733740800— if that matches, your code is right and the issue is runtime (clock/secret).