WalletD developers
Platform

Security for integrators

Credential formats and lifecycles, scopes, user tokens, webhook signing, what the platform records and for how long, what is not offered today, and how to report a vulnerability.

This page is about the security properties you can rely on while building, and the ones you have to supply yourself. It says what is not offered as plainly as what is, because an integration designed around a control that does not exist is worse than one designed without it.

Credentials

Three things authenticate against the API, and they fail in different ways.

Tenant API keys

sk_{env}_{key-id}_{secret}

The env segment is sandbox or live. The key id is a UUID: it is identifying, not secret, and it exists so verification is a single row lookup rather than a scan of every hash. The secret is 32 random bytes.

  • Only an Argon2id hash of the secret is stored. The token is displayed exactly once, at creation. There is no recovery path, and asking us for it will not produce one. If you lose it, issue a new key and revoke the old one.
  • Keys expire. Every key is issued with a one year lifetime. A credential nobody is ever forced to rotate is a credential nobody rotates; a year makes rotation planned work rather than an outage. Put the expiry in your calendar the day you issue the key.
  • Verification has two caches. authsvc caches successful verification for 60 seconds; walletd caches positive introspection for 60 seconds and negative answers for 10 seconds. Cache keys are SHA-256 digests. Revocation clears the receiving authsvc process's cache, but other replicas may retain theirs. With these defaults, allow approximately two minutes plus request latency for stale acceptance to disappear, and verify the old key is rejected. This is a cache-derived allowance, not a measured propagation SLA.
  • Failures are opaque. A wrong secret, a revoked key, an expired key and an environment mismatch all answer the same way. Nothing in the response tells an attacker which of those it was.
  • Issuance and revocation are audited in the same database transaction as the change itself.

Rotation

Rotation is create-then-revoke, in that order, and the overlap is the point.

  1. Issue a second key with the same scopes.
  2. Deploy it. Both keys are valid, so nothing is racing a restart.
  3. Confirm the old key has stopped being used.
  4. Revoke the old key, and allow for both cache layers, then verify the old key is refused before closing the rotation.

Do this on a schedule rather than after an incident. A key you rotate quarterly is a key you can rotate in an hour when you have to.

End-user tokens

An RS256 JWT that acts for exactly one user, minted by your backend exchanging its API key.

ClaimWhat it carries
subThe user's external id, as you supplied it
tenThe tenant the issuing key belongs to
envsandbox or live, from the key
scpThe granted scopes
iss, exp, jtiIssuer, expiry, token id

The kid header names the signing key. walletd verifies the token locally against the JWKS with the issuer, the expiry and the algorithm all pinned, so verification costs no network round trip on the hot path.

Default TTL is 15 minutes; the maximum accepted is 1 hour. Ask for longer and you get an hour, silently clamped. Two rules bound what such a token can do: its scopes cannot exceed the scopes of the key that minted it (403 scope_exceeds_key at exchange time), and it is pinned to its own user regardless of scope, so balances:read on a user token does not read a different user's balance.

A user token cannot be revoked before it expires. There is no revocation list for them and no introspection call in their path: the short TTL is the revocation mechanism. If you need a user's access to stop, stop minting tokens for them and wait out at most the TTL you chose. This is the single strongest argument for leaving the TTL at 15 minutes rather than raising it for convenience.

If your users sign in through the WalletD identity provider instead, their access token is their wallet credential and there is no exchange step. Those tokens carry a fixed consumer scope set decided by the platform, not by the token.

Signing key rotation

Rotation is live and needs no restart. A new signing key is inserted; running replicas reload on an interval, so the JWKS gains the new kid while the old one keeps verifying; walletd fetches on first sight of an unknown kid. Old keys are retired only after every token they signed has expired. Use the longest effective lifetime across user and staff tokens, plus clock-skew allowance, measured from the last issuance with that key. Defaults are a one-hour user-token maximum and a two-hour staff-token lifetime; deployments can override them. A one-hour retirement wait is insufficient for default staff sessions. The tooling refuses to retire the last active key.

The verifier holds no lock across its network fetch: one refresh is in flight at a time and stale-but-known keys are served immediately, so a slow JWKS fetch degrades to slightly stale keys rather than to a stalled request queue.

Scopes and least privilege

A scope is resource:action. A credential carries a set, and a handler refuses anything outside it with 403 insufficient_scope. The full table is on Authentication.

Two habits are worth the small effort:

Issue a key per job, not per company. The key your checkout service uses needs payments:write and nothing else. The key your reconciliation job uses needs ledger:read and payments:read. A single * key used by six services means every one of those services is a full compromise, and an incident in any of them forces you to rotate for all six at once.

Mint user tokens with the scopes that screen needs. The exchange lets you narrow, and narrowing is free. A wallet screen that only displays a balance does not need transfers:write.

Some surfaces refuse user tokens entirely no matter the scope. Credit lines and tier limits are key-or-staff only, because a user must never be able to raise their own limits.

Webhooks

Every delivery carries a signature over the exact bytes of the body.

X-Wallet-Signature: t=1757772000,v1=<hex hmac-sha256>
X-Wallet-Event-Id: 018f...

The signed value is t and the raw body joined by a literal dot, "<t>.<body>", HMAC-SHA256 under your endpoint secret, hex encoded. The secret is whsec_ plus 32 random bytes and is returned exactly once, at registration.

Verifying correctly is four steps, and the order matters:

  1. Read the raw body bytes, before any JSON parsing or framework re-serialization. A body that has been decoded and re-encoded will not verify, and that is the signature doing its job.
  2. Recompute the HMAC and compare in constant time. A byte-by-byte comparison that returns early leaks the answer.
  3. Reject if t is more than 5 minutes from your clock in either direction. This is what stops a captured delivery being replayed at leisure.
  4. Only then parse the payload.

Working implementations in six languages are on Webhooks.

Idempotency on your side too. Delivery is at-least-once: an endpoint that times out after processing will be retried, up to 12 attempts spread over roughly a day. Deduplicate on the verified body’s (tenant_id, id); the X-Wallet-Event-Id header is unsigned and advisory. Acknowledge after durable inbox acceptance. Commit the worker’s local business effect and processed marker in one transaction. An event processed twice is a bug you own, not one the signature can catch.

Endpoint URLs are validated at registration, not per delivery. The scheme must be http or https, the host must resolve, and it must not resolve to a loopback, private, link-local, multicast or unspecified address. Redirects are refused at delivery time: a public endpoint answering 307 would otherwise re-post your fully signed body to any target it named, on every retry. If your infrastructure moves an endpoint, register the new URL rather than redirecting to it.

Idempotency is a safety property, not a convenience

Every money-moving POST requires an Idempotency-Key. A request without one is refused before anything happens.

walletd stores the response with its domain state and outbox in one local transaction. ledgerd commits the accounting posting separately. A failure between those commits can leave money posted before a response is stored; deterministic posting keys and stored-result probes support repair on retry. Keep the same request and key when the outcome is uncertain. Once stored, the response body and status are replayed. Reusing the key with different parameters returns 409 idempotency_key_reuse. Machine keys share a tenant namespace, including merchant-bound keys: include an integration or merchant identifier in the key.

Derive the key from your own business object. A key from uuid4() per attempt, or from the clock, gives every retry a fresh key and therefore moves money on every retry. Idempotency has the rules and the per-language retry helpers.

What is recorded, and for how long

WhatContentsRetention
Idempotency recordsYour key, a hash of the request, the stored responseKept. No expiry is configured in walletd today, so a replay keeps answering. The ledger's own copy of the key is purged after 30 days by default
Webhook events and delivery attemptsThe event payload, the target endpoint, each attempt's status and response code30 days by default, then pruned in bounded batches
Audit logEvery credential and configuration change, with actor, object, before and afterKept. Append-only by database trigger, and hash-chained per tenant so a rewrite that bypassed the trigger is detectable
Access logSensitive reads, with the support grant that authorised them where one appliesKept. Append-only by the same trigger
Request logsMethod, path, status, byte count, duration, request idOperational retention, set per deployment

Request logs carry no bodies, no headers and no credentials. Internal errors are logged and never returned: a handler that fails unexpectedly answers a generic 500 problem rather than leaking a stack trace or a query. Request bodies are size-capped at 1 MiB for the API and 64 KiB for the auth endpoints, and the auth endpoints reject unknown JSON fields outright.

Transport

TLS terminates at your deployment's edge, in front of the gateway, and http is redirected to https there. The gateway itself and every service behind it listen on the deployment's own private network. The negotiated cipher suites and the minimum TLS version are therefore a property of that edge, configured with the rest of your infrastructure, and not something the application pins. Ask for your deployment's edge configuration if your review needs a number.

Only three surfaces are publicly routed in the reference deployment: the API gateway, the identity endpoints and the operator console. walletd, authsvc and ledgerd are not directly reachable from outside.

Outbound webhook deliveries are made over whatever scheme you registered. Register https.

What is not offered today

Stated so you can design around it, rather than discover it during a security review.

  • No IP allowlisting for your API calls. There is no configuration, per tenant or per key, that restricts which source addresses may present a credential. Source addresses are used at the edge as a rate-limit identity, which is a capacity control and not an access control. Your API key is the only thing standing between a caller and your tenant, so treat it accordingly.
  • No mutual TLS for partner callers. There is no client-certificate authentication on the public API and no trust bundle for validating one. The edge terminates TLS with a server certificate and does not request a client certificate. Authentication is the bearer credential and nothing else.
  • No service-to-service mTLS inside a deployment. Services communicate over the deployment's private network. This is tracked as a gap in the compliance mapping rather than claimed as implemented.
  • No static egress address or client certificate for outbound webhooks. You cannot pin deliveries to a known source address or certificate. Verify the signature. That is the control this platform actually provides, and it is a stronger one than an address allowlist because it survives infrastructure changes on both sides.
  • No sandbox and live data-plane split. The environment is carried on the credential and verified against the key record, but keys of both environments issued against one deployment reach the same data. Non-production work belongs in a non-production deployment. See Environments, limits and quotas.
  • No tenant-managed encryption keys and no bring-your-own-KMS for the API surface. Signing keys and secrets are managed within the deployment.

If your security review requires one of these, say so during the commercial conversation rather than during integration. They are product decisions with owners, not oversights.

Reporting a vulnerability

Send it to security@walletd.io. The same address is published at /.well-known/security.txt on this site.

Please include enough to reproduce: the endpoint, the request, what you observed and what you expected. If you have a proof of concept, describe it rather than running it against a live partner deployment.

What we commit to. A report is triaged on the same severity model as any other incident, and acknowledged on the target published for the severity we assign it: 30 minutes for anything we can confirm puts money or availability at risk, and we tell you in the acknowledgement which severity we chose and why. The targets are on Support, SLAs and status.

What we ask. Give us a reasonable window to fix before publishing, do not access, modify or retain data belonging to anyone else, and do not run automated scanning that degrades a live deployment. We do not run a paid bounty programme; we do credit reporters who want to be credited.

Next

On this page