Skip to content

Security

Keys, scopes, IP allow-lists, and what the API does when one of them leaks.

Most payment breaches are not clever. A secret key ends up in a repository, in a mobile bundle, or in a screenshot pasted into a chat, and whoever finds it can do everything you can do. The controls on this page exist to make that mistake small: harder to make, narrower when made, and quick to undo.

Three key types, and only one is safe in public

A key is a prefix, a dot, and 96 random characters. The prefix is the whole security model.

PrefixLives inMay do
pk. / pk_test.Browsers and mobile appsStart payments, read reference data
sk. / sk_test.Your server, onlyEverything your team can do
rk. / rk_test.Your server, scopedOnly the resources you ticked

Wajub stores the SHA-256 hash, never the value, and looks keys up by that hash. A leaked database backup hands out no usable credential. The consequence for you is the ordinary one: the value is shown once, when you create or roll the key, and nowhere afterwards.

Five gates a request passes before it reaches an endpoint

Knowing the order explains the status codes, which are easy to misattribute.

GateRefuses withWhen
Environment routingNothingThe prefix picks the sandbox or the live database
Browser detection403An sk. key arrives with an Origin or Referer header
Credential check401Unknown, revoked, inactive, or past expires_at
IP allow-list403The caller's IP is not on the key's list
Key class406 or 403A public key on a private route, or a scope the key lacks

Narrow the key before you need to

Two controls apply to every key type, including public ones, and almost nobody turns them on.

An IP allow-list pins a key to the machines that should be using it. Entries are exact IPv4 or IPv6 addresses, or CIDR ranges in either family, and an empty list means no restriction. A call from anywhere else gets 403 IP address not allowed for this API key, whatever the key's type. If your backend leaves a fixed egress address, this turns a leaked key into a useless string.

An expiry puts a date on the key. Past it, every call answers 401. Set one on anything temporary: a contractor's key, a migration script, a demo.

A key that only works from your cluster
{
"type": "restricted",
"allowed_ips": [
"203.0.113.0/24",
"2001:db8::/32"
],
"expires_at": "2026-12-31T23:59:59Z",
"permissions": [
"payment.read",
"payment.write",
"refund.read"
]
}

What a restricted key can be scoped to

Scopes are {resource}.{read|write}. The verb decides which half applies: GET, HEAD and OPTIONS need read, everything else needs write.

ResourceScopes
paymentpayment.read, payment.write
customercustomer.read, customer.write
transfertransfer.read, transfer.write
refundrefund.read, refund.write
recipientrecipient.read, recipient.write
invoiceinvoice.read, invoice.write
disputedispute.read, dispute.write
webhookwebhook.read, webhook.write
eventevent.read, event.write
linklink.read, link.write
identityidentity.read, identity.write
taxtax.read, tax.write
balancebalance.read
accountaccount.read
settingssettings.read, settings.write

A restricted key is refused on any route that maps to no resource at all, with 403 This API key cannot access this resource. That is deliberate, and it means a restricted key is an allow-list rather than a filter: if a route is not in the table above, the key cannot reach it. Four paths are the exception and stay open to any valid key, because they carry nothing sensitive: /channels, /countries, /currencies, and the API root.

Note the two read-only rows. balance and account have no write scope, so a restricted key can read a Sync connection but never create one. Use a private key for that, on your server.

Rolling a key is instant, in both directions

Rolling replaces the value in place and purges the caches for the old one, so the old value stops working immediately. There is no grace period and no overlap. Rolling the key your production checkout is using takes your checkout down until the new value is deployed.

The safe sequence has no window at all.

  1. 1

    Create a second key of the same type

    Your team can hold several. The new one works from the moment it exists.

  2. 2

    Deploy it

    Ship the new value to every service that calls Wajub, and wait until none of them are still running the old one.

  3. 3

    Check the old key is idle

    Its last_used_at stops moving once nothing is using it.

  4. 4

    Delete the old key

    Now it is dead, and nothing noticed.

Roll in place only when the key is already compromised. There, the outage is the cheaper of the two costs.

When a key has leaked

  1. 1

    Revoke it

    Delete or deactivate it in the Dashboard. Every call using it answers 401 from that moment.

  2. 2

    Put a working key back

    Create a replacement and deploy it, following the sequence above for whatever still runs.

  3. 3

    Read what was done with it

    Konsole has the request log. Look for calls from IPs you do not recognise, and for refunds, transfers and beneficiaries in particular.

  4. 4

    Close the hole

    A key in a git history is still in the git history after the file is edited. Rotate first, then clean.

Webhook secrets are credentials too

Every delivery is signed with a secret that belongs to the endpoint, not to your API key. Verify before you parse, and rotate the secret the same way you rotate a key, with POST /webhooks/{id}/rotate-secret.

Two failures are worth naming because both look like working code. Parsing the body before checking the signature means anyone who knows your URL can invoke your fulfilment. And comparing signatures with == leaks timing: use your language's constant-time comparison, after checking that the two strings are the same length, since several implementations throw rather than return false on a length mismatch.

Signature verification has the manual implementation for stacks without an SDK.

Sandbox and live are different databases

The key prefix chooses the database connection before a single query runs. A pk_test. or sk_test. key reaches the sandbox database and physically cannot see a live row, and the reverse holds too. Sandbox object ids carry test_ so a value that crossed environments is visible on sight.

This is also why an id from one environment answers 404 in the other, rather than a permission error. There is nothing there to permit.

On your side of the line

  • Keys go in environment variables, never in source, and never in a variable your bundler inlines.
  • NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_ and their equivalents are a promise to publish. Only a pk. key may carry one.
  • Take the amount from your own database when you create a payment. A price that came from the browser is a price the browser chose.
  • Give each service its own restricted key, scoped to what that service does.
  • Turn on required two-factor authentication for your team in the Dashboard, and give teammates the narrowest role that lets them work.
  • Keep an expiry on anything temporary.

Reporting a vulnerability

Send it to security@wajub.com rather than opening a public issue. Include what you found, how to reproduce it, and how to reach you.

What did you think of this content?