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.
| Prefix | Lives in | May do |
|---|---|---|
pk. / pk_test. | Browsers and mobile apps | Start payments, read reference data |
sk. / sk_test. | Your server, only | Everything your team can do |
rk. / rk_test. | Your server, scoped | Only 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.
| Gate | Refuses with | When |
|---|---|---|
| Environment routing | Nothing | The prefix picks the sandbox or the live database |
| Browser detection | 403 | An sk. key arrives with an Origin or Referer header |
| Credential check | 401 | Unknown, revoked, inactive, or past expires_at |
| IP allow-list | 403 | The caller's IP is not on the key's list |
| Key class | 406 or 403 | A public key on a private route, or a scope the key lacks |
A private key used from a browser is reported, not just refused
Wajub reads Origin and Referer. An sk. key sent with either is rejected with 403, and an
alert email goes to the key's owner, throttled to one per key per hour. You will hear about the
leak, which is the point, but by then the key is in a bundle someone downloaded. Roll it.
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.
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.
| Resource | Scopes |
|---|---|
payment | payment.read, payment.write |
customer | customer.read, customer.write |
transfer | transfer.read, transfer.write |
refund | refund.read, refund.write |
recipient | recipient.read, recipient.write |
invoice | invoice.read, invoice.write |
dispute | dispute.read, dispute.write |
webhook | webhook.read, webhook.write |
event | event.read, event.write |
link | link.read, link.write |
identity | identity.read, identity.write |
tax | tax.read, tax.write |
balance | balance.read |
account | account.read |
settings | settings.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
Create a second key of the same type
Your team can hold several. The new one works from the moment it exists.
- 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
Check the old key is idle
Its
last_used_atstops moving once nothing is using it. - 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
Revoke it
Delete or deactivate it in the Dashboard. Every call using it answers
401from that moment. - 2
Put a working key back
Create a replacement and deploy it, following the sequence above for whatever still runs.
- 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
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 apk.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.