Skip to content
Live in production, in shadow mode: FingerTrackID recommends, your system decides.Status →

HMAC authentication

Every call is signed with FTID-HMAC-SHA256-V1: HMAC-SHA-256 keyed with the bytes of your credential’s secret, over a canonical string.

Exactly ten lines in UTF-8, separated by LF (\n), without a trailing LF.

The ninth line is empty: it is the Content-Encoding, which does not exist in this protocol. The MAC is lowercase hexadecimal, 64 characters, and goes in the X-FTID-Signature header.

Canonical string
FTID-HMAC-SHA256-V1
<key_id>
POST
/v1/assessments
<X-FTID-Timestamp>
external_event_id:<external_event_id>
request_id:<X-FTID-Request-Id>
application/json
<lowercase hexadecimal SHA-256 of the exact body bytes>
Header
X-FTID-Signature: v1=<key_id>:<mac>
  • The key is the decoded bytes of the secret, never the hexadecimal text. The console delivers the production secret once as 32 random bytes encoded as 64 hexadecimal characters. Read them only from your backend’s secret manager and call decodeSecret(text, 'hex') before signing. The reference signer also supports an explicitly issued canonical base64 encoding; never guess the encoding or paste the value into a browser, AI or local file.
  • The hash covers the bytes you send. Re-serializing the JSON after signing (different key order, spacing or escaping) produces 401.
  • key_id and request_id are canonical, lowercase UUIDv7.
  • The signed path is /v1/assessments, literally, without origin, query or trailing slash.
  • There is no downgrade: if any part of the v1 envelope fails, the server does not try another protocol.

Every authentication failure — missing or malformed signature, unknown key_id, wrong MAC, revoked or suspended credential, or a credential outside its overlap window — returns the same response: 401, type authentication-failed, title Autenticação falhou (the contract fixes this Portuguese title), no detail, with WWW-Authenticate: FTID-HMAC-V1 realm="assessments". The response does not reveal whether the key_id exists. Code against status and type, not against that header or the title.

Only after the signature has been verified for a known credential can two other 401 types appear, about the attempt rather than the credential: timestamp-out-of-window and request-replayed (below). They never answer a request whose signature or credential failed. With a valid signature and a credential that lacks the route’s capability, the response is 403 forbidden.

  • X-FTID-Timestamp is the second at which you sign the attempt, not the event’s occurred_at. Outside ±300 s of the server clock: 401 timestamp-out-of-window.
  • X-FTID-Request-Id is the nonce. Resending the same request_id gives 401 request-replayed. Never reuse a request_id, not even after 401, 403, 429 or 503.
  • Generate UUIDv7 with crypto.randomUUIDv7(), available from Node.js 24.16.0 on the 24 line and from 26.1.0; Node.js 22 and 25 do not have it. Do not use crypto.randomUUID(): it generates version 4, which the API rejects.
  • Keep your backend clock synchronized (NTP).
Once per attempt
import { randomUUIDv7 } from 'node:crypto'
// The second at which you sign this attempt, not the event's occurred_at.
const timestamp = String(Math.floor(Date.now() / 1000))
// A new nonce for every attempt. randomUUID() makes version 4, which the API rejects.
const requestId = randomUUIDv7()
Headers of one attempt
X-FTID-Timestamp: 1788523200
X-FTID-Request-Id: <a new lowercase UUIDv7>
X-FTID-Signature: v1=<key_id>:<mac>

The reference signer does both for you.

  • A credential has a closed set of capabilities, frozen in its version. Changing a capability requires a new credential.
  • Rotation changes the key_id. The managed workflow creates a new credential and requires secure installation of its new material. The previous credential is revoked at the end of an overlap of at most 24 hours. A revoked key_id never comes back.
  • Suspected leak: tell FingerTrackID immediately. The emergency rotation has no overlap.
  • Prepare your backend to hold two credentials during a rotation and to switch without a deploy.

A public synthetic secret — the 32-byte sequence 00..1f — that authenticates nothing, anywhere. Sign the vector’s body with it and you must get exactly the MAC on the right.

The full vector, with body, canonical string and headers: assessment-hmac-v1-test-vector.json.

Vector Route Expected MAC
Assessment POST /v1/assessments df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b

Two files, with no dependency other than node:crypto, tested against the vector above on every change. They need crypto.randomUUIDv7(): Node.js 24.16.0 or later on the 24 line, or 26.1.0 or later. On Node.js 22 or 25 the signer fails as soon as it is imported (on Vercel, set the project’s Node.js version to 24.x). To keep a login independent of that, load it with await import() inside your fail-open try, as the integration guidance wrapper does.

Save both files in the same folder and run the self-check on the same Node.js that runs your backend. It prints OK and the vector’s MAC.

The self-check carries the vector values inline; it only needs the signer next to it.

Terminal
node ftid-hmac-v1-self-check.mjs
Expected output
OK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b

One send per attempt, always over the same bytes.

readSecretFromYourVault, assessmentRequest, keyId and the timeout are yours. In production, apiOrigin is 'https://api.fingertrackid.com', exactly as written: https://, lowercase host, no trailing slash, path, query or credentials. The dash shows the same value as Endereço da API de risco.

  • prepareAttempt only signs a body whose text is exactly what JSON.stringify writes; any other text gets FTID_BODY without being signed. That is how it blocks duplicate keys at any level. Build the body with a single JSON.stringify call.
  • postAttempt does not retry on its own, does not follow redirects and reads at most 65536 bytes of response; one byte more becomes FTID_INDETERMINATE. Validate the response against the schema before using it.
  • If you already have an HTTP client, use only prepareAttempt, send attempt.body and attempt.headers unchanged, and cap the response read at the same limit.
server.mjs
import { decodeSecret, postAttempt, prepareAttempt } from './ftid-hmac-v1.mjs'
// Once, at startup, from your secret manager. The console delivers it as hex.
const secret = decodeSecret(await readSecretFromYourVault(), 'hex')
// Once per logical operation: validate against the schema, then keep these exact bytes.
const body = Buffer.from(JSON.stringify(assessmentRequest), 'utf8')
// Once per transport attempt, including every retry of the same operation.
const attempt = prepareAttempt({ secret, keyId, body })
// Above the 5000 ms default budget of a `null` LOGIN/v1.1 body plus the network.
const result = await postAttempt(apiOrigin, attempt, 6500)
// result.status 201/200: parse result.body by its schema_version (event-assessment.v1.1 or
// event-assessment.v1) and check the event and its tag (v1.1) or journey (v1).
// Retryable Problem or FTID_INDETERMINATE: wait, then call prepareAttempt again with the same body.
ftid-hmac-v1.mjs
// FTID-HMAC-SHA256-V1 signing for the FingerTrackID server-to-server API.
//
// Node.js 24.16.0 or newer, ES module: `crypto.randomUUIDv7()` was added in 24.16.0, and an
// older release fails at import time instead of signing. It imports only `node:crypto` and uses
// platform globals (`Buffer`, `fetch`, `AbortSignal`, `TextDecoder`). It belongs in your
// backend: the inbound secret must never reach a browser, a mobile app, a log or an analytics
// tool.
//
// Contract: contracts/openapi/assessment.v1.yaml (securitySchemes.ftidHmacV1) and
// contracts/openapi/feedback.v1.yaml. Normative vectors reproduced byte for byte:
// contracts/openapi/assessment-hmac-v1-test-vector.json and hmac-v1-test-vector.json.
import { createHash, createHmac, randomUUIDv7 } from 'node:crypto'
export const PROTOCOL = 'FTID-HMAC-SHA256-V1'
export const ASSESSMENT_PATH = '/v1/assessments'
export const MAX_BODY_BYTES = 16384
// Every response of the three routes is at most this many bytes, inclusive: the
// `x-ftid-max-response-body-bytes` both contracts publish.
export const MAX_RESPONSE_BYTES = 65536
export const MAX_TIMESTAMP = 253402300799
// `AbortSignal.timeout()` takes a 32-bit signed delay; above it Node aborts at once or throws.
export const MAX_TIMEOUT_MS = 2147483647
const MIN_SECRET_BYTES = 32
const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const EXTERNAL_EVENT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
const FEEDBACK_PATH =
/^\/v1\/assessments\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/(?:customer-actions|outcomes)$/
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
/**
* Decodes the secret received once in the console. The HMAC key is the decoded bytes, never the
* hexadecimal or base64 text itself. Surrounding whitespace from copy and paste is removed;
* anything else that is not canonical hex/base64 is rejected instead of silently truncated.
*/
export function decodeSecret(text, encoding) {
if (typeof text !== 'string') throw new TypeError('FTID_SECRET_ENCODING')
const value = text.trim()
let secret
if (encoding === 'hex') {
secret = Buffer.from(value, 'hex')
if (secret.toString('hex') !== value.toLowerCase()) throw new TypeError('FTID_SECRET_ENCODING')
} else if (encoding === 'base64') {
secret = Buffer.from(value, 'base64')
if (secret.toString('base64') !== value) throw new TypeError('FTID_SECRET_ENCODING')
} else {
throw new TypeError('FTID_SECRET_ENCODING')
}
if (secret.byteLength < MIN_SECRET_BYTES) throw new TypeError('FTID_SECRET')
return secret
}
/**
* A canonical lowercase UUIDv7 for `X-FTID-Request-Id`, new for every transport attempt, from
* the platform generator. `crypto.randomUUID()` produces version 4, which the API rejects, and
* silently ignores a `{ version: 7 }` option; the format check below catches that swap.
*/
export function newRequestId() {
const id = randomUUIDv7()
if (!UUID_V7.test(id)) throw new Error('FTID_REQUEST_ID')
return id
}
/** The signed resource tag is fixed by the path, never chosen by the caller. */
function signedIdTag(path, signedId) {
if (path === ASSESSMENT_PATH) {
if (typeof signedId !== 'string' || !EXTERNAL_EVENT_ID.test(signedId)) {
throw new TypeError('FTID_EXTERNAL_EVENT_ID')
}
return 'external_event_id'
}
const match = typeof path === 'string' ? FEEDBACK_PATH.exec(path) : null
if (match === null) throw new TypeError('FTID_PATH')
if (signedId !== match[1]) throw new TypeError('FTID_ASSESSMENT_ID')
return 'assessment_id'
}
/**
* The ten lines the MAC covers, joined by LF and without a final LF. The last line hashes
* the exact body bytes: never parse and re-serialize JSON between signing and sending.
*/
export function canonicalString({ keyId, path, timestamp, signedId, requestId, body }) {
const tag = signedIdTag(path, signedId)
if (typeof keyId !== 'string' || !UUID_V7.test(keyId)) throw new TypeError('FTID_KEY_ID')
if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_TIMESTAMP) {
throw new TypeError('FTID_TIMESTAMP')
}
if (typeof requestId !== 'string' || !UUID_V7.test(requestId)) {
throw new TypeError('FTID_REQUEST_ID')
}
if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) {
throw new TypeError('FTID_BODY')
}
return [
PROTOCOL,
keyId,
'POST',
path,
String(timestamp),
`${tag}:${signedId}`,
`request_id:${requestId}`,
'application/json',
'',
createHash('sha256').update(body).digest('hex'),
].join('\n')
}
/** HMAC-SHA-256 over the UTF-8 canonical string, as 64 lowercase hexadecimal characters. */
export function sign({ secret, ...input }) {
if (!(secret instanceof Uint8Array) || secret.byteLength < MIN_SECRET_BYTES) {
throw new TypeError('FTID_SECRET')
}
return createHmac('sha256', secret).update(canonicalString(input), 'utf8').digest('hex')
}
/** Exactly these five headers. Do not add X-Client-Id, a nonce header or Content-Encoding. */
export function buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }) {
const resourceHeader =
path === ASSESSMENT_PATH ? 'X-FTID-External-Event-Id' : 'X-FTID-Assessment-Id'
return {
'Content-Type': 'application/json',
'X-FTID-Timestamp': String(timestamp),
'X-FTID-Request-Id': requestId,
[resourceHeader]: signedId,
'X-FTID-Signature': `v1=${keyId}:${mac}`,
}
}
/**
* Every route reads one JSON object in strict UTF-8 without a BOM, so anything else is refused
* here instead of being sent. `fatal: true` rejects invalid UTF-8 instead of replacing it, and
* `ignoreBOM: true` keeps a leading BOM as U+FEFF, which `JSON.parse` rejects.
*
* The text must also be exactly what `JSON.stringify` writes for it. The API refuses a repeated
* member at any depth, even one spelled with escapes, while `JSON.parse` silently keeps the
* last one. Serializing the parsed value again writes each member once, so a body that repeats
* one can never equal its own re-serialization and is refused here without a JSON reader of
* its own. This also refuses some bodies the API accepts but `JSON.stringify` never writes:
* whitespace, a trailing newline, escapes such as `\/` and number spellings such as `1.5e5`.
* Build the body with one `JSON.stringify` call, whose output always passes.
*
* Every check runs inside the `try`, so a failure of any kind, including a stack overflow on a
* deeply nested body, is reported as `FTID_BODY`.
*/
function signedIdFrom(path, body) {
let document
let accepted
try {
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(body)
document = JSON.parse(text)
accepted =
JSON.stringify(document) === text &&
document !== null &&
typeof document === 'object' &&
!Array.isArray(document) &&
(path === ASSESSMENT_PATH || feedbackStrings(document))
} catch {
throw new TypeError('FTID_BODY')
}
if (!accepted) throw new TypeError('FTID_BODY')
if (path === ASSESSMENT_PATH) return document.external_event_id
return FEEDBACK_PATH.exec(path)?.[1]
}
/**
* The feedback routes refuse any member name or string value, once decoded, that is not
* well-formed UTF-16 (a lone surrogate from `\ud800`) or that contains U+0000. `JSON.stringify`
* only escapes both, so they reach the API unless refused here.
*/
function feedbackStrings(value) {
if (typeof value === 'string') return value.isWellFormed() && !value.includes('\u0000')
if (value === null || typeof value !== 'object') return true
return Object.entries(value).every(([key, item]) => feedbackStrings(key) && feedbackStrings(item))
}
/**
* Prepares one transport attempt. Serialize the logical request once, keep those exact bytes
* with your operation record and call this again for every retry: each call returns a new
* request ID, the current timestamp and a new signature over the same bytes.
*/
export function prepareAttempt({
secret,
keyId,
path = ASSESSMENT_PATH,
body,
nowMs = Date.now(),
}) {
// Bounded before the copy, so an oversized body is never copied or parsed; `sign` checks the
// copy again.
if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) {
throw new TypeError('FTID_BODY')
}
const bytes = Buffer.from(body)
const signedId = signedIdFrom(path, bytes)
const timestamp = Math.floor(nowMs / 1000)
const requestId = newRequestId()
const mac = sign({ secret, keyId, path, timestamp, signedId, requestId, body: bytes })
return {
path,
body: bytes,
requestId,
headers: buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }),
}
}
/**
* Reads the response body, counting the bytes that actually arrive instead of trusting a header,
* and stops at the first byte past `MAX_RESPONSE_BYTES`. The API never sends more and never
* persists an assessment whose response would not fit, so a longer body is a failed read: the
* stream is cancelled and `postAttempt` reports `FTID_INDETERMINATE`.
*/
async function readResponse(response) {
if (response.body === null) return new Uint8Array(0)
const reader = response.body.getReader()
const chunks = []
let size = 0
for (;;) {
const { done, value } = await reader.read()
if (done) return Buffer.concat(chunks, size)
size += value.byteLength
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel()
throw new RangeError('response above MAX_RESPONSE_BYTES')
}
chunks.push(value)
}
}
/**
* Sends one prepared attempt without retrying or following redirects. `FTID_INDETERMINATE`
* means the outcome is unknown (network error, timeout, a response that cannot be read or is
* above `MAX_RESPONSE_BYTES`): recover by preparing a NEW attempt over the same bytes, never by
* resending this one. Plain HTTP is accepted only for a loopback test double.
*/
export async function postAttempt(apiOrigin, attempt, timeoutMs) {
// Node's own error for an unparsable URL carries the rejected text, credentials included, in
// `error.input`; only the fixed code leaves here.
let origin
try {
origin = new URL(apiOrigin)
} catch {
throw new TypeError('FTID_API_ORIGIN')
}
const loopback = LOOPBACK_HOSTS.has(origin.hostname) && origin.protocol === 'http:'
// An origin only: no path, query, fragment, credentials or trailing slash. The scheme's
// default port may be spelled out (`https://api.example:443`): URL drops it from `.origin`.
const defaultPort = origin.protocol === 'https:' ? '443' : '80'
const spelled = apiOrigin === origin.origin || apiOrigin === `${origin.origin}:${defaultPort}`
if (!spelled || (origin.protocol !== 'https:' && !loopback)) {
throw new TypeError('FTID_API_ORIGIN')
}
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
throw new TypeError('FTID_TIMEOUT')
}
// The path is checked again here, not trusted from `prepareAttempt`: an edited `attempt.path`
// such as `@evil.example/x` would move the signed request to another host. Only the fixed
// routes pass, and the final URL must still be on the API origin.
const routePath = attempt?.path
if (
typeof routePath !== 'string' ||
(routePath !== ASSESSMENT_PATH && !FEEDBACK_PATH.test(routePath))
) {
throw new TypeError('FTID_PATH')
}
const target = new URL(routePath, origin.origin)
if (target.origin !== origin.origin) throw new TypeError('FTID_PATH')
let response
let bytes
try {
response = await fetch(target.href, {
method: 'POST',
headers: attempt.headers,
body: attempt.body,
redirect: 'error',
signal: AbortSignal.timeout(timeoutMs),
})
bytes = await readResponse(response)
} catch {
throw new Error('FTID_INDETERMINATE')
}
let text
try {
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
} catch {
throw new Error('FTID_INDETERMINATE')
}
return {
status: response.status,
contentType: response.headers.get('content-type'),
correlationId: response.headers.get('x-correlation-id'),
retryAfter: response.headers.get('retry-after'),
body: text,
}
}
ftid-hmac-v1-self-check.mjs
// Checks ftid-hmac-v1.mjs against the published synthetic vector for POST /v1/assessments
// (contracts/openapi/assessment-hmac-v1-test-vector.json). Run it next to that file:
//
// node ftid-hmac-v1-self-check.mjs
//
// It prints `OK <mac>` and exits 0 on a byte-for-byte match, or prints MISMATCH and exits 1.
// The secret is the public test sequence 00..1f: it authenticates nothing anywhere.
import { buildHeaders, canonicalString, decodeSecret, sign } from './ftid-hmac-v1.mjs'
const VECTOR = {
secretHex: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
keyId: '01996f2a-1111-7000-8000-000000000001',
path: '/v1/assessments',
timestamp: 1788523200,
signedId: 'login_20260904_000001',
requestId: '01a06c4a-3e00-7000-8000-000000000002',
body: '{"schema_version":"event-assessment-request.v1","external_event_id":"login_20260904_000001","idempotency_key":"idem_login_20260904_000001","occurred_at":"2026-09-04T12:00:00Z","subject_ref":"subject_123","account_ref":"account_456","event_context_token":"ect_login_20260904_000001","journey":"LOGIN","action":"LOGIN_ATTEMPT","journey_context":{},"deadline_ms":null}',
canonicalString: [
'FTID-HMAC-SHA256-V1',
'01996f2a-1111-7000-8000-000000000001',
'POST',
'/v1/assessments',
'1788523200',
'external_event_id:login_20260904_000001',
'request_id:01a06c4a-3e00-7000-8000-000000000002',
'application/json',
'',
'f7cdd806f169384d0cc3bf5f63654999d5b78aa68045da4ddce075b09953bd44',
].join('\n'),
mac: 'df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b',
signatureHeader:
'v1=01996f2a-1111-7000-8000-000000000001:df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b',
}
const input = {
keyId: VECTOR.keyId,
path: VECTOR.path,
timestamp: VECTOR.timestamp,
signedId: VECTOR.signedId,
requestId: VECTOR.requestId,
body: Buffer.from(VECTOR.body, 'utf8'),
}
const canonical = canonicalString(input)
const mac = sign({ secret: decodeSecret(VECTOR.secretHex, 'hex'), ...input })
const headers = buildHeaders({ ...input, mac })
if (
canonical === VECTOR.canonicalString &&
mac === VECTOR.mac &&
headers['X-FTID-Signature'] === VECTOR.signatureHeader
) {
process.stdout.write(`OK ${mac}\n`)
} else {
console.error('MISMATCH: this signer does not reproduce the published vector')
process.exitCode = 1
}