identify(): sign on your server
Actions exist only for identified users. Your server signs the identity with the site's secret; the widget passes it along; a wrong signature is a 401.
Actions ("reschedule Thursday") exist only for identified users, and the identity is the whole authorisation boundary, so a client-asserted user id is never trusted. Your server signs the payload with the site's identify secret (shown once in the dashboard, never sent to the browser):
string_to_sign = "actessia-identify-v1\n" + canonical_json(payload)
hmac = lower_hex( HMAC-SHA256( site_identify_secret, string_to_sign ) )
signature = { "version": "v1", "hmac": hmac }
canonical_json is RFC 8785 (JCS): keys sorted, no whitespace, UTF-8 unescaped.
payload.issued_at is unix seconds and must be within 5 minutes of our clock and no older
than 1 hour; an optional expires_at caps it further. Otherwise the runtime answers
401 invalid_signature and the visitor stays anonymous.
Payload fields: user_id (required), email, name, plan, entitlements[], traits{}
(flat scalars; keep keys ASCII and numbers integer), issued_at, expires_at.
Node
@actessia/sdk/server, or the dependency-free version below (verified against the shared test
vector in CI):
import { signIdentifyPayload } from "@actessia/sdk/server";
const { payload, signature } = signIdentifyPayload(process.env.ACTESSIA_IDENTIFY_SECRET, {
user_id: user.id, email: user.email, plan: user.plan,
});
res.json({ payload, signature }); // the page passes both to Actessia.identify()
// Actessia identify() signing: Node.js (≥ 18), no dependencies.
//
// hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" + canonical_json(payload) ) )
//
// canonical_json is RFC 8785 (JCS): keys sorted by UTF-16 code units, no whitespace, JSON
// number/string formatting as in JSON.stringify. Prefer `signIdentifyPayload` from
// "@actessia/sdk/server" when you can add a dependency; this file is the standalone version.
//
// Usage from your request handler:
// const { payload, signature } = signIdentify(process.env.ACTESSIA_IDENTIFY_SECRET, {
// user_id: user.id, email: user.email, plan: user.plan, issued_at: Math.floor(Date.now() / 1000),
// });
// // hand { payload, signature } to the page → Actessia.identify(payload, signature)
//
// Run as a script to verify against the shared vector:
// node docs/snippets/identify.mjs packages/schemas/fixtures/vectors/identify-signature.json
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
export function canonicalJson(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
}
export function signIdentify(secret, payload) {
const stringToSign = `actessia-identify-v1\n${canonicalJson(payload)}`;
const hmac = createHmac("sha256", secret).update(stringToSign, "utf8").digest("hex");
return { payload, signature: { version: "v1", hmac } };
}
if (process.argv[2]) {
const v = JSON.parse(readFileSync(process.argv[2], "utf8"));
const canonical = canonicalJson(v.payload);
const { signature } = signIdentify(v.secret, v.payload);
const ok = canonical === v.canonical && `actessia-identify-v1\n${canonical}` === v.string_to_sign && signature.hmac === v.signature.hmac;
console.log(ok ? "OK identify.mjs reproduces the vector" : `MISMATCH\n${canonical}\n${signature.hmac}`);
process.exit(ok ? 0 : 1);
}
Python
from identify import sign_identify
payload = {"user_id": user.id, "email": user.email, "plan": user.plan, "issued_at": int(time.time())}
signature = sign_identify(os.environ["ACTESSIA_IDENTIFY_SECRET"], payload)
"""Actessia identify() signing: Python 3.9+.
hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\\n" + canonical_json(payload) ) )
canonical_json is RFC 8785 (JCS). With the `jcs` package (`pip install jcs`) it is exact for
every JSON value. The standard-library fallback below is exact for payloads made of strings,
integers, booleans, null, lists and objects, i.e. every IdentifyPayload that does not put
non-integer numbers in `traits`.
Usage from your view / handler:
payload = {"user_id": user.id, "email": user.email, "plan": user.plan, "issued_at": int(time.time())}
signature = sign_identify(os.environ["ACTESSIA_IDENTIFY_SECRET"], payload)
# hand {"payload": payload, "signature": signature} to the page → Actessia.identify(payload, signature)
Run as a script to verify against the shared vector:
python docs/snippets/identify.py packages/schemas/fixtures/vectors/identify-signature.json
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import sys
from typing import Any
try:
if os.environ.get("ACTESSIA_SNIPPET_NO_JCS"):
raise ImportError
import jcs # type: ignore[import-not-found]
def canonical_json(value: Any) -> str:
return jcs.canonicalize(value).decode("utf-8") # type: ignore[no-any-return]
except ImportError:
def canonical_json(value: Any) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def sign_identify(secret: str, payload: dict[str, Any]) -> dict[str, str]:
string_to_sign = "actessia-identify-v1\n" + canonical_json(payload)
digest = hmac.new(secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
return {"version": "v1", "hmac": digest}
if __name__ == "__main__":
with open(sys.argv[1], encoding="utf-8") as f:
v = json.load(f)
canonical = canonical_json(v["payload"])
sig = sign_identify(v["secret"], v["payload"])
ok = canonical == v["canonical"] and "actessia-identify-v1\n" + canonical == v["string_to_sign"] and sig["hmac"] == v["signature"]["hmac"]
print("OK identify.py reproduces the vector" if ok else f"MISMATCH\n{canonical}\n{sig['hmac']}")
sys.exit(0 if ok else 1)
PHP
<?php
// Actessia identify() signing: PHP 8.1+, no dependencies.
//
// hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" . canonical_json(payload) ) )
//
// canonical_json is RFC 8785 (JCS). This implementation is exact for payloads made of strings,
// integers, booleans, null, lists and maps with ASCII keys, every normal IdentifyPayload.
// Two PHP-specific caveats: an empty PHP array encodes as `[]`, so omit an empty `traits`
// (or pass `new stdClass`); and avoid non-integer numbers in `traits` (JCS formats floats the
// way JavaScript does, which PHP does not always match).
//
// Usage from your controller:
// $signed = actessia_sign_identify($_ENV['ACTESSIA_IDENTIFY_SECRET'], [
// 'user_id' => $user->id, 'email' => $user->email, 'plan' => $user->plan, 'issued_at' => time(),
// ]);
// // hand $signed (payload + signature) to the page → Actessia.identify(payload, signature)
//
// Run as a script to verify against the shared vector:
// php docs/snippets/identify.php packages/schemas/fixtures/vectors/identify-signature.json
declare(strict_types=1);
function actessia_canonical_json(mixed $value): string
{
if (is_object($value)) {
$value = get_object_vars($value);
$isMap = true;
} else {
$isMap = is_array($value) && !array_is_list($value);
}
if (is_array($value) && !$isMap) {
return '[' . implode(',', array_map('actessia_canonical_json', $value)) . ']';
}
if (is_array($value)) {
ksort($value, SORT_STRING);
$parts = [];
foreach ($value as $k => $v) {
$parts[] = json_encode((string) $k, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS) . ':' . actessia_canonical_json($v);
}
return '{' . implode(',', $parts) . '}';
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS);
}
/** @return array{payload: array<string, mixed>, signature: array{version: string, hmac: string}} */
function actessia_sign_identify(string $secret, array $payload): array
{
$stringToSign = "actessia-identify-v1\n" . actessia_canonical_json($payload);
return ['payload' => $payload, 'signature' => ['version' => 'v1', 'hmac' => hash_hmac('sha256', $stringToSign, $secret)]];
}
if (PHP_SAPI === 'cli' && isset($argv[1]) && realpath($argv[0]) === __FILE__) {
$v = json_decode((string) file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$canonical = actessia_canonical_json($v['payload']);
$signed = actessia_sign_identify($v['secret'], $v['payload']);
$ok = $canonical === $v['canonical'] && "actessia-identify-v1\n" . $canonical === $v['string_to_sign'] && $signed['signature']['hmac'] === $v['signature']['hmac'];
echo $ok ? "OK identify.php reproduces the vector\n" : "MISMATCH\n$canonical\n{$signed['signature']['hmac']}\n";
exit($ok ? 0 : 1);
}
Ruby
# Actessia identify() signing: Ruby 3.0+, standard library only.
#
# hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" + canonical_json(payload) ) )
#
# canonical_json is RFC 8785 (JCS). This implementation is exact for payloads made of strings,
# integers, booleans, nil, arrays and hashes with ASCII keys, every normal IdentifyPayload.
# Avoid non-integer numbers in `traits` (JCS formats floats the way JavaScript does). The
# `json-canonicalization` gem is an exact alternative if you need arbitrary values.
#
# Usage from your controller:
# signed = actessia_sign_identify(ENV.fetch("ACTESSIA_IDENTIFY_SECRET"),
# { "user_id" => user.id, "email" => user.email, "plan" => user.plan, "issued_at" => Time.now.to_i })
# # hand signed (payload + signature) to the page → Actessia.identify(payload, signature)
#
# Run as a script to verify against the shared vector:
# ruby docs/snippets/identify.rb packages/schemas/fixtures/vectors/identify-signature.json
require "json"
require "openssl"
def actessia_canonical_json(value)
case value
when Hash
h = value.to_h { |k, x| [k.to_s, x] }
"{" + h.keys.sort.map { |k| k.to_json + ":" + actessia_canonical_json(h[k]) }.join(",") + "}"
when Array
"[" + value.map { |x| actessia_canonical_json(x) }.join(",") + "]"
when Float
value == value.floor && value.abs < 1e21 ? value.to_i.to_s : value.to_s
else
value.to_json # String, Integer, true, false, nil
end
end
def actessia_sign_identify(secret, payload)
string_to_sign = "actessia-identify-v1\n" + actessia_canonical_json(payload)
{ "payload" => payload, "signature" => { "version" => "v1", "hmac" => OpenSSL::HMAC.hexdigest("SHA256", secret, string_to_sign) } }
end
if __FILE__ == $PROGRAM_NAME && ARGV[0]
v = JSON.parse(File.read(ARGV[0]))
canonical = actessia_canonical_json(v["payload"])
signed = actessia_sign_identify(v["secret"], v["payload"])
ok = canonical == v["canonical"] && "actessia-identify-v1\n" + canonical == v["string_to_sign"] && signed["signature"]["hmac"] == v["signature"]["hmac"]
puts(ok ? "OK identify.rb reproduces the vector" : "MISMATCH\n#{canonical}\n#{signed["signature"]["hmac"]}")
exit(ok ? 0 : 1)
end
Then, on the page: Actessia.identify(signed.payload, signed.signature). On logout, call
Actessia.reset() so the next person on that browser starts anonymous.
Rotating the secret
Rotate it from the dashboard's Install page. Signatures made with the old secret stop verifying at once, so deploy the new secret to your server first, then rotate.