Signing & security
The exact X-MMOLove-Signature HMAC scheme — sign over the raw body, the header format, the replay window, idempotency, and secret rotation.
Every inbound referral event is authenticated with an HMAC-SHA256 signature keyed by your per-server secret. This page is the authoritative reference for that scheme; if a snippet ever disagrees with this page, this page wins.
The one rule that matters most: sign the raw body
The #1 integration bug: signing a re-serialized object instead of the exact
bytes you send. Build the JSON body string once, compute the MAC over that
exact string, and send that same string as the request body. If you serialize
twice (once to sign, once to send), whitespace or key order can differ and the MAC
will — correctly — fail to verify with a 401.
The MMOLove endpoint reads the raw request body first (await req.text())
and verifies the signature over those exact bytes before it parses any JSON. So
the contract on your side is symmetric: serialize once, sign those bytes, send
those bytes.
What gets signed
The MAC is computed over the string "<t>.<rawBody>" — the timestamp, a literal
dot (.), then the raw JSON body:
mac = HMAC_SHA256(secret, t + "." + rawBody) // lowercase hexsecret— your per-server signing secret (minted when you enable referrals; rotatable any time).t— the Unix-seconds timestamp you put in the header (see below). It is part of the signed string, so it can't be tampered with independently.rawBody— the exact request-body bytes.
The header format
Send the signature in the X-MMOLove-Signature header:
X-MMOLove-Signature: t=<unix>,v1=sha256=<hex>[,kid=<key-id>]| Field | Required | Meaning |
|---|---|---|
t | yes | Unix seconds at signing time. Drives the replay window and is part of the signed string. Must be a positive integer. |
v1 | yes | sha256=<hex> where <hex> is the lowercase-hex HMAC_SHA256(secret, "<t>.<rawBody>"). The sha256= scheme prefix is required. |
kid | no | Optional key id for rotation. Carried through; mapping kid → secret is yours to manage. |
The parser is tolerant of field order and surrounding whitespace, and
ignores unknown fields — but t and v1 must both be present and well-formed, or
the header is rejected as malformed (400).
Verification order (and why)
When your request arrives, MMOLove:
- Parses the header. Missing/malformed
torv1→400. - Recompiles the MAC over
"<t>.<rawBody>"and compares it to yourv1using a constant-time (timing-safe) comparison. Mismatch →401(bad_signature). - Checks the replay window on
tafter the MAC verifies. Outside the window →401(stale).
The MAC is verified before the clock check on purpose: a forged timestamp can't be used to probe the replay window, because it would change the signed string and fail the MAC first.
Replay window
The signature timestamp t must be within ±5 minutes (300 seconds) of MMOLove
server time. Anything older or further in the future is rejected as stale
(401).
Implications:
- Keep your server clock in sync (NTP). A drifting clock will silently fail signatures once it exceeds ±5 min.
- Sign at send time — don't pre-compute
tand queue the request for minutes. - On retry, re-sign with a fresh
t(a staletfrom the first attempt will be rejected). Keep the sameserver_event_idso the retry is still deduped.
Idempotency & dedup
Signing keeps requests authentic; idempotency keeps retries safe.
- Every event carries a
server_event_id— your idempotency key for that event. - Dedup is on the tuple
(token, type, server_event_id)— never ontokenalone — soregisteredandqualifiedcan both land for one referral. - The event row is inserted before any state processing, so a duplicate is
absorbed at the gate and returns
200(never a4xx), with{ "ok": true, "duplicate": true }.
So: retry freely. Reuse the same server_event_id for the same logical event and
a replay is a guaranteed no-op. Use a new server_event_id for each distinct
event (e.g. reg-<player> for registration, qual-<player> for qualification).
Per-server secret + rotation
Each server has its own signing secret. The endpoint looks the secret up by
server_id, so one compromised server can never spoof another's conversions —
secrets are isolated.
Rotation: on the Referrals tab, Rotate secret mints a new secret and shows it once. Rotating invalidates the old secret immediately, so:
- Rotate in the dashboard and copy the new secret.
- Update your server's stored secret (env var / secrets manager).
- Resume signing with the new secret.
The kid field exists for callers who want to coordinate a zero-downtime cutover
(tag each request with the key id you signed with). MMOLove carries kid through;
you own the kid → secret mapping on your side.
The signing secret is server-side only. Never ship it to a browser, a game client, or a public repo. If it leaks, rotate immediately.
Correct signing snippet per language
Each snippet builds the body once, signs "<t>.<rawBody>", and sends the same
bytes. These are the signing cores; the full request helpers live in the
SDK guides.
<?php
// $raw is the exact JSON string you will POST as the body.
$t = time();
$mac = hash_hmac('sha256', $t . '.' . $raw, $secret); // lowercase hex
$sig = "t={$t},v1=sha256={$mac}";
// Header: X-MMOLove-Signature: {$sig}Note Convert.ToHexString is upper-case by default in .NET — lower-case it. The
endpoint normalises the incoming hex, but lower-casing keeps your value identical
to the other stacks.