Security
Security & architecture.
How Heatrail is built, and which guarantees are enforced below the application. Every control on this page names the mechanism that enforces it, so a reviewer can check it rather than take it on trust.
Reviewer summary
Advisory only, fail-open, hard 800 ms budget. Raw e-mail and phone never reach us. Append-only ledger with a nightly Ed25519 anchor and a verification tripwire. EU only. Controls mapped to OWASP ASVS Level 2.
Threat model
What we defend, and against whom.
Heatrail holds three assets worth attacking: the hashed identifiers and derived heat state of a shared network, the integrity of the history that justifies that state, and, indirectly, the availability of every checkout that calls us. The third is protected by design rather than by uptime: we removed our ability to affect a checkout at all.
Figure 1
- A participating shop reading the network
- The realistic first attack is a legitimate customer using the API as a lookup oracle: probe identifiers, read the score, reconstruct other shops' fraud data. Countered by requiring every check to carry a real
session_refororder_ref, by answering in categories rather than evidence, by per-shop opaque trail aliases, and by per-key rate limiting. Above the limits, a key whose traffic stops looking like checkout traffic is frozen. The single bulk path, one history scan per Pro shop, carries a written ceiling and is set out under oracle barriers. Why an anchored, category-only answer is what makes the network unsiftable rather than merely inconvenient to sift is set out in Why the network cannot be enumerated. - A participating shop poisoning the network
- A mistaken or malicious merchant labelling competitors' customers as fraud. Countered by label semantics, only
fraudandchargebackcreate heat,realcreates none, one active label per order, and above them by limiting what one label can reach. A fraud label makes the buyer's device and IP hot for every Pro shop at once. E-mail and phone, which anyone can type in anyone's name, carry full weight elsewhere only once they are verified or tied to that same device. The network tier needs two independent owners. Every one of those signals is advisory and never stops a purchase. What corroboration requires, and why independence is a property of ownership rather than of accounts, is set out in Corroboration. - An attacker holding our database
- Countered by never receiving the data worth stealing. Contact details arrive already hashed and are hashed again under material that lives outside the database. API credentials are stored AES-256-GCM encrypted under a key that is not the framework key, and card data never exists here at all.
- An attacker holding an operator account
- Countered by TOTP, IP-bound sessions, a re-challenge on sensitive actions and an audit row for every read. Detection rules are deliberately unreachable from the API: a full operator takeover still cannot change how anyone is scored.
- Us, quietly editing history
- The one an outsider cannot verify by trusting us harder. Countered by an append-only hash chain, a nightly Ed25519 signature over its head, and a nightly walk that compares the two. Section 07 names the three layers and what each one enforces.
- Heatrail being slow or down
- Treated as a threat to the shop, not to us. Answered structurally: the client abandons at 800 ms and the order proceeds. Availability is explicitly not a confidentiality or integrity control in our model.
Non-capabilities
What Heatrail cannot do.
Each of these is a capability we removed rather than a policy we promise to follow. A removed capability cannot be misconfigured back.
- It cannot block, delay, cancel or hold a purchase. The response is data your code may use or ignore. There is no callback, no webhook that gates a payment, and no configuration that turns advisory into enforcing.
- It cannot receive a shopper's name, delivery address, raw e-mail address, raw phone number, order lines, or card number. Unknown fields are rejected, not ignored.
That claim is about the shopper, and one field falls outside it. When a shop applies a label it sendslabelled_by, free text of at most 128 printable characters naming whoever in the shop made the decision, and a shop is free to put a staff member's e-mail address there. That is shop-staff data, not shopper data. The permanent chain keeps only an opaque actor reference, and the readable value lives in a separate provenance record with its own 90 day window. Where it goes and how long it stays is stated field by field in the API reference. - It cannot show one shop another shop's order, incident or evidence. A request for a resource belonging to another shop is answered as if it did not exist.
- It cannot have its scoring rules changed through the API. The rules endpoint is read-only by construction. Rule changes ship as a code deploy and emit a ledger event.
- It cannot send a report to police, a CERT or a partner on its own. The authorities memo is a draft a human reviews and sends.
- It cannot rewrite its own history. The role the application connects with holds
INSERTandSELECTon the ledger and nothing else, which section 07 sets out layer by layer.
Availability
Fail-open, and the drill that proves it.
The plugin and the SDK enforce a hard 800 ms budget. Past it the client abandons the request, marks the check degraded, puts the payload on a local retry queue and returns control to your checkout. The SDK refuses a zero-valued timeout at construction time, because the underlying HTTP client reads zero as no timeout at all. That would silently defeat the budget, so it is rejected rather than accepted.
Labelling behaves the opposite way on purpose. A check must never throw, but a label must never be silently lost, so it raises an error your queue can retry.
- checks served on the first day in production, 1 September 2026
- 78
- median round trip, measured across that day
- 386 ms
- 95th percentile, against a hard 800 ms budget
- 610 ms
- staging drill: to complete a checkout with Heatrail deliberately made unreachable
- 16 ms
The outage drill was run in staging on the same day: DNS resolution for the Heatrail host was broken deliberately, the purchase completed, the check returned degraded in 16 ms, and the queued payload drained when service returned. The three latency figures are production measurements.
Request auth
HR1: signed requests, single-use nonces, no oracles in the errors.
Every shop request is signed. There is no bearer token that replays forever and no API key in a query string.
- Signature
- Every request is signed with a per-shop credential, over the method, the path, the time, a single-use nonce and the body. The canonical form is one shared implementation used by both the SDK and the API, never two, and both test suites assert the same shared set of test vectors, so a client and the server cannot drift apart on what was signed. It is documented line by line, with a worked vector you can reproduce, in the API reference.
- Replay protection
- A request is accepted only inside a short window around server time, and each nonce is accepted once. A second presentation of the same nonce is refused. The window and how long a nonce is remembered are part of the integration contract rather than a secret, and the API reference states both.
- There is a third rule that most implementations miss: after the nonce store restarts its history is gone, so a request stamped from before that restart could be a replay we can no longer detect. Exactly those are rejected. Current-time requests pass.
- No authentication oracle
- Every failure, malformed header, unknown key, bad signature, skewed clock, wrong scope, returns the same generic
401. An unknown key still burns one HMAC against a dummy value so both paths cost the same work, and the comparison itself is constant-time. A distinct "insufficient scope" answer would confirm that a signature had verified, so we do not give one. - Scopes, rotation, freeze
- Keys carry
checkorlabelscope. Rotation is overlapping: the retired key keeps a grace window through an expiry timestamp and is permanently revoked by an hourly job once that deadline passes. A key frozen for review is the single deliberate exception to the generic 401, it answers403 key_frozen, and only after the signature verified. - Rate limiting
- Per key, a burst cap and a sustained cap, both atomic counters, and a rejected request counts toward them exactly as an accepted one does, so a caller cannot probe faster by being wrong. The caps are published in the API reference, because an integrator has to build against them. Above the caps, a key whose traffic stops resembling checkout traffic is frozen for review.
- Idempotency
- Every
POSTrequires anIdempotency-Key. The claim is durable rather than cached, and it is taken before any side effect runs, so the guarantee survives a cache loss. A retry with the same body replays the stored response, and a different body under the same key is a409. A server error releases the claim, so the retry genuinely re-runs rather than being locked out by a failure that never had an effect. How long a claim is held, and the error codes for each case, are in the API reference.
Hashing boundary
Two stages, two keys, two machines.
Figure 2
Stage one runs inside your shop
Your server normalises the e-mail address or phone number and derives a keyed value from it, under material shared across participating shops. That derived value is what travels to us. The raw value never leaves your infrastructure, so there is no configuration mistake on our side that can cause us to receive it.
Stage two runs only on the Heatrail API
We derive a second value, under material that has never been anywhere but this server, before anything is stored. Only that second value is ever written, so the values in our database are not the values you computed, and a copy of the database taken without that material cannot be lined up against what you send.
The roles are separated on purpose
Three roles, three key materials, no overloading: the shared material is what lets two shops recognise the same identifier as each other, the second material never leaves the API server, and the credential you sign requests with plays no part in identifier hashing at all. The constructions themselves, the field formats and what you actually implement are documented where you need them, in the SDK reference and the API reference. What is on this page is the property, which is what stays true whichever way the mechanism is built.
Empty and placeholder values are refused, not hashed
A hash of an empty string or of a shop's placeholder address would become one enormous node linking every guest checkout in the network. The hashing function refuses an empty input outright and returns nothing for a placeholder. The contract is that the field is then omitted from the request entirely, and the API additionally rejects known placeholder hashes on arrival.
Hashed data is still personal data
A hashed e-mail address is personal data under the GDPR, and Heatrail processes it as such. The shared network salt lives on every participating shop's server, because recognising the same person across shops needs every shop to compute the same value. Identity without identities explains how that design protects the raw value.
Stage two has a work factor, and here is what it buys
Stage one is still a single keyed hash, computed in the shop. Stage two, computed only on our server, is a deliberately slow and memory-hard derivation: argon2id with 12 MiB of memory for every guess. There is still no per-record salt, because recognising the same person across shops needs every shop's value to match. So somebody who holds a copy of the database and both key materials at the same time can still test candidate values, but each guess now costs about 13 ms and 12 MiB on one core. Measured, that is a realistic ten-million Estonian mobile numbering space in about 37 hours and the whole eight-digit space in about 15 days, where the same searches took 52 s and nine minutes before.
A number without its conditions is the wrong number, so: one core of an Apple M1 Pro, PHP 8.4 from the command line, libsodium 1.0.20. More cores divide those times, and hardware built for this does better still. It does not protect a short target list. A hundred numbers take about a second and a half. And rows written before the change still carry the old fast hash until ordinary retention removes them, which is 90 days, or longer where a row is evidence for an open fraud case.
Minimisation
An allowlist, and the barriers that stop it becoming an oracle.
What the API accepts
- Stage-one hashes of e-mail and phone, and an optional persistent pseudonym.
- The IP address the order came from, canonicalised before storage.
- Order context as classes and ranges only: product risk class, quantity, amount range, account-age class, referral-source class.
- An order reference and a session pseudonym, at least one is required.
- Optional device characteristics, switchable per shop: screen geometry, pixel ratio, core and memory class, touch, time zone, language list, platform, a user-agent class, and canvas and WebGL hashes. Read as what they are: a device configuration is not a device, and a device is not a person. Thousands of handsets on factory settings share one configuration, and behind a real device there can be a second-hand phone, a family, or a carrier address shared by a town. That is why device similarity on its own never raises a warning.
What it refuses
- Names, delivery addresses, raw e-mail or phone values.
- Card numbers or any payment credential.
- Order lines, basket contents, exact amounts.
- Raw user-agent strings inside the device object.
- Any field not on the list, unknown keys at the top level, in the device object or in the order context are a validation error, so a well-meaning integration cannot start smuggling data in by adding a field.
Oracle barriers
A check must be anchored to a real checkout: without a session or order reference the request is rejected and nothing is stored. Responses carry categories rather than records, and it is worth saying exactly what that permits. A reason line may name the kind of match, how many shops the match spans, and how long ago, in coarse units. It carries no identifier, no hash, no other shop's evidence, and no shop's identity. So a published line like 2 shop(s) affected tells you the match is corroborated somewhere else in the network and names nobody: there is no shop to look up and no record to resolve it against. A count is a category. The exact response shape is in the API reference. Trail aliases are per-shop opaque, so the same trail appears under a different identifier to each shop and cannot be used as a stable handle to probe the network. Validation messages name the offending field and never echo the submitted value.
Cross-shop requests answer 404, never 403
Asking for another shop's order, incident, trail or memo returns 404 not_found, the same answer as asking for something that does not exist. 403 means this exists and you may not have it, and that single bit is enough to enumerate: a shop could walk a space of order references and learn which ones are real somewhere else in the network, who else sells to whom, at what volume, under what identifiers. 404 gives the attacker the same answer for "no such order" and "not your order", so the probe returns nothing at all. The same uniformity applies on the internal interface the partner portal uses, where a non-allowlisted caller, an ownership mismatch and an unknown identifier all produce one indistinguishable 404. Denying that one bit is worth more than a more specific error for an integrator who typed the wrong reference.
There is one bulk endpoint, and it is an exception we accept rather than one we missed. A Pro shop may run one history scan in its life, through POST /v1/history-scan/sessions: a single session, open for seven days, of at most 250,000 past orders in requests of at most 100. Each row carries an order date and at least one of a hashed e-mail, a hashed phone or an IP, and each row is answered strong, weak or none in categories only: which field matched, whether in another shop or the network tier, a coarse shop count and an age, never an identifier or a shop's name. No row is kept. The rows are not anchored to a checkout we saw, so for that one session the anchor rule above does not hold, and 250,000 rows is the written ceiling on what a fraudster posing as a shop can learn from it. A second scan needs us to reset the shop, with the reason written to our audit log.
Integrity
An append-only history, in three layers.
Every consequential event, checks that mattered, labels, operator actions, scoring configuration changes, is appended to a hash-chained ledger. Each row stores the SHA-256 of its canonicalised payload and a row hash over seq, event type, payload hash and the previous row hash, starting from a genesis value of sixty-four zeroes. Appends serialise on a database advisory lock, so sequence numbers are contiguous by construction and an out-of-band insert breaks the chain, deliberately, because that break is the tripwire. What the history is for, and why a technical file for the authorities is then a matter of minutes rather than weeks, is Evidence in minutes.
Figure 3
- Layer 1, the chain walk
- A nightly command recomputes the chain from genesis and compares every row against its stored hashes. A single flipped byte, a deleted row or an inserted row is reported at the first divergent sequence number, and the command exits non-zero.
- Layer 2, the signed anchor
- A chain walk alone cannot distinguish the real chain from one that was entirely re-hashed forward. So at 00:05 UTC the day's head is anchored with an Ed25519 signature over
date:seq:row_hash, and the verification at 00:15 UTC checks that signature against the operator's configured public key, not the key stored beside the anchor. Re-signing a rewritten anchor with an attacker's key therefore does not pass. In production the signing key is mandatory: the command refuses to run rather than sign with a throwaway key. - A missing anchor is not silently treated as healthy either. Once the oldest ledger row is older than a full anchor cycle, the absence of an anchor is itself a failure, otherwise deleting the anchors would be the cheapest way to defeat the anchor defence.
- Layer 3, database grants
- The unprivileged runtime role has
UPDATE,DELETEandTRUNCATErevoked on the ledger, the audit log and the anchor table, holding onlyINSERTandSELECT, and the ledger's sequence cannot be moved either. That is what turns "our code does not do that" into "the database refuses". - In force in production since 2 September 2026, and verified the way this page prefers: by connecting as that role and attempting the forbidden writes.
UPDATEon the ledger,DELETEon the audit log,permission denied, from the database itself. - And the boundary of that claim, stated here in full. Those revocations bind the role the application connects with at runtime. They do not bind the separate owner credential the hosting platform holds for schema changes. We keep it out of the processes that serve requests. Against anything that reaches that credential the chain relies on detection rather than prevention: the hash chain, the nightly signed anchors, and a record of the chain’s length kept outside the database. So a ledger that is emptied, or unhooked from its anchors, cannot present itself as a young one that nobody has anchored yet. Section 5(3) of the data processing agreement you would accept carries the same boundary: it dates the switch, says the privileges bind the runtime role and the application but not the owner credential the platform holds, and says that against that credential the chain relies on detection. Read the two side by side, because that comparison is the check.
Ledger intact: 3 entries verified against the signed anchor.
The ledger and the audit log are also the two tables the retention purge never touches, they carry only identifiers, hash prefixes and event metadata, so the proof survives while the personal data it referenced is deleted on schedule.
Which raises the question of who did it, since an entry that cannot name an actor is not much of an audit trail. Every entry carries provenance, split along the same line. The permanent chain records an opaque actor reference. The readable half, an operator's address or the labelled_by value a shop sent, lives in a separate provenance record with its own 90 day window and is deleted by the same nightly purge. So who did it is answerable while it matters and gone afterwards, and the chain still verifies without it. The opaque reference is pseudonymous rather than anonymous, and we hold it on the same Article 17(3) grounds as the rest of the evidence.
Operator console
The console is the highest-value target, and is treated as one.
- TOTP, always
- A valid password opens a session that can reach nothing but the TOTP endpoints until the challenge passes. A used TOTP code cannot be presented a second time inside its own validity window.
- IP-bound sessions
- The canonical login address is pinned to the session. A request from a different address does not merely get denied. The session is invalidated, because a bound session seen from elsewhere is treated as stolen.
- Step-up on sensitive actions
- Cooling a trail and changing the trusted-IP set require a fresh TOTP challenge within the last 15 minutes, regardless of how the session was opened. A trusted-address login skips the login prompt only, never the step-up.
- Every read is audited
- Each authenticated console read writes an audit row into the same append-only table as writes, recording actor, action and the addressed resource, never the response data, hashes or identifiers it returned.
- Rules are unreachable from the API
- There is no write endpoint for scoring configuration. It is versioned in the repository and ships by code deploy, each change emitting a ledger event. A full operator-account takeover still cannot alter detection.
Key material
How credentials and keys are held.
- Shop API credentials
- Generated as a key identifier plus 32 random bytes, shown to the shop exactly once, and stored only as AES-256-GCM ciphertext with a fresh random nonce and an authentication tag per record. Decryption fails closed on any tampering.
- Separate from the framework key
- The vault key is its own environment value, deliberately not the application key that protects sessions and cookies. Rotating the framework key must never brick every shop's API credential.
- Fail fast, never half-configured
- A missing or wrong-length vault key, hashing material shorter than the minimum, or a missing anchor signing key in production each stop the process rather than degrade quietly to a weaker mode.
- Never logged
- Key material is marked sensitive at the parameter level so it is redacted from stack traces. Signatures and key values are not logged, and a decryption failure logs only the public key identifier.
- Card data
- Never touches Heatrail. Payment details live only in Stripe's Checkout and Billing Portal, which the partner portal redirects to.
Infrastructure
One region, one platform, no data leaving the EU.
- Hosting
- Laravel Cloud on AWS, region eu-central-1 (Frankfurt). Application, Postgres database, key-value store and queue workers all run in that region. Laravel Cloud and AWS are our only sub-processors.
- Backups
- Seven days of retention with point-in-time restore, held in the same region as the database they protect.
- Transport
- HTTPS only. HSTS is emitted on every secure response, alongside
X-Content-Type-Options: nosniff,X-Frame-Options: DENYandReferrer-Policy: no-referrer. - Content Security Policy
- A JSON API needs to load nothing, so JSON responses carry
default-src 'none'. HTML responses get a hardened document policy with no script andframe-ancestors 'none'. The policy follows the document being served rather than the hostname it was reached by, a distinction that was a real finding in our own review, not a hypothetical. - Scheduled work
- Ledger anchor 00:05 UTC, ledger verification 00:15 UTC, retention purge 03:30 UTC, merchant reminders 09:00 UTC, expired-key revocation hourly.
Posture
Controls mapped to OWASP ASVS Level 2.
The controls on this page are mapped, item by numbered item, to OWASP ASVS Level 2. Each item names the file that implements the control and the test that proves it, and the document separates what a reviewer can verify from outside from what needs a shell on the platform.
The mapping is revised with the code, and a control counts when it holds in production rather than in the repository. That is why the database-grant layer in section 07 is stated against the role the application actually connects with.
Send a note to hello@heatrail.eu and we will share the current version with your security team.
Disclosure
Reporting a vulnerability.
Report to security@heatrail.eu. We welcome coordinated disclosure and will not pursue good-faith security research. Please give us a reasonable window before publishing, and please do not run tests that would degrade a shop's checkout. That is the one thing this product exists to protect.
Our machine-readable policy is served from this site at /.well-known/security.txt and conforms to RFC 9116: a Contact, a Policy pointing back at this section, a Canonical URI and an Expires date, which the RFC requires and which is the field most published policy files are missing. It is generated from the same build-time constants as the rest of the site, so it cannot drift from the address this page shows you.
security@heatrail.eu is live and monitored, and a report reaches a person rather than a queue. We credit reporters who want to be credited.