Trust
Security
What we never store, why keys are hashed, and what must never reach browser code.
What we protect, how, and what is your responsibility. Written to be checkable rather than reassuring.
What we never store #
- Your private keys or seed phrase. Animica Pay never asks for them and has nowhere to put them. In the default settlement mode nothing here can spend your funds.
- A plaintext secret API key. Only a keyed hash. This is why we cannot show you a key twice.
- Card numbers or bank details. There is no card rail here; the only payment instrument is an ANM transfer.
- A password in a recoverable form. Passwords are scrypt-hashed with the parameters stored alongside the digest, and compared in constant time.
Passwords, seed phrases, private keys, full secret keys and auth tokens are also never logged. Logs are redacted at the point of writing, not scrubbed afterwards.
Why keys are hashed rather than encrypted #
A secret key is 32 characters of CSPRNG output over a 62-character alphabet — around 190 bits. There is no dictionary to attack, so a slow KDF would buy nothing but latency on every API request. Instead:
- the stored value is an HMAC-SHA256 of the key under a server-side pepper of at least 32 bytes that lives in the environment, not in the database, so a stolen dump is not enough to authenticate;
- a deterministic blind index finds the row in one lookup instead of scanning and comparing every digest, which would leak timing and get slower with every merchant onboarded;
- comparison is constant time, and when no record matches we still compare against a decoy digest so "unknown key" and "wrong key" cost the same;
- an unrecognised hash scheme is refused, never silently downgraded;
- the hashing helper fails closed if the pepper is missing or too short — it refuses to hash at all rather than store weak digests.
What a merchant must never put in browser code #
A publishable key (apk_…) is the browser-safe one. It is printed into HTML by design, which is exactly why it cannot create charges.
Tenant isolation #
Every query is scoped by merchant id taken from the session or the API key — never from the URL. A row id from a request is loaded and then ownership-checked, and another merchant’s row returns 404, not 403, so ids cannot be enumerated by watching status codes.
Sessions and the dashboard #
| Control | Behaviour |
|---|---|
| Session id | opaque random value, server-side lookup — so logout and revocation are real |
| Absolute lifetime | 12 hours |
| Idle lifetime | 2 hours |
| Cookie flags | HttpOnly; SameSite=Lax; Secure; Path=/ |
| Password change | destroys every other session for that user |
| Password rules | at least 12 characters; length over composition, because length is what resists guessing |
| Every state-changing form | CSRF token bound to the session, constant-time compared, plus a same-origin check |
The public API uses no cookies at all, which is why it needs no CSRF token — and why an API key is never accepted from a cookie.
Browser-side hardening #
- A Content-Security-Policy with
script-src 'self',object-src 'none'andbase-uri 'none'. No inline script anywhere, including on this page. frame-ancestors 'none'on the dashboard, admin and these docs. Only the hosted checkout page relaxes it, and then only to the specific merchant origin that is allowed to embed it.X-Content-Type-Options: nosniff, a strict referrer policy, andgeolocation,microphoneandcameradisabled by policy.- Every dynamic value on every page is HTML-escaped by default; emitting markup requires an explicit opt-in that shows up in a diff. Merchant-supplied names, descriptions and URLs are treated as attacker-controlled, and a merchant URL is dropped unless it is a plain https or same-site link.
Chain-specific rules we enforce for you #
- Nothing is final without a successful receipt. Inclusion is not execution on this chain; a historical executor bug turned a failed transfer into a revert with a zeroed state root, and that is how phantom deposits were once credited.
markPaidrefuses withoutreceiptOk === true. 0x1002SPHINCS+ payout addresses are rejected. They exist but cannot spend.- bech32m, not bech32. The checksum constant is asserted explicitly, because confusing the two has already broken a wallet release on this chain.
- Reorgs un-settle payments instead of leaving them marked paid on evidence that no longer exists.
- Test mode cannot move real ANM, and the guard fails closed.
Reporting a vulnerability #
Report it to the Animica security contact rather than opening a public issue, include the request_id from any error body, and expect to be asked for a reproduction. Do not test against other merchants’ data — a tenant-isolation report can be demonstrated entirely with two accounts you control.