Key rotation

View as Markdown

The wp: tokenization key is rotated periodically. If you fetch /wp-config at runtime and cache it with a real TTL, you will never notice.

If you use the Payment Element, you do not need this page. The frame fetches and refreshes the key itself. See Payment Element.

This page is for integrations that hold their own copy of the key: a server that seals cards before calling POST /v1/charges, a native app, or an in-house encryptor. It explains the mechanism so you can verify you inherit it, and names the one mistake that opts you out.


Warning: DO NOT HARDCODE THE /wp-config RESPONSE#

Fetch it at runtime. Refetch every time your cache expires.

If you paste public_key or kid into your source, a config file, an environment variable, or a build artifact, your integration will break the moment the key rotates. It breaks for every card at once rather than gradually, and no notification saves you, because the whole point of the mechanism is that it does not need one.


How it works#

Three moving parts:

1. /wp-config carries a key id.

json
{
  "kid": 1,
  "public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
  "alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
  "info": "worthy-card-v1",
  "token_ttl_seconds": 600
}

2. Every token embeds the kid it was encrypted with. You copy it from the config response into the envelope. When the charge arrives, we know which key opens it without guessing.

3. We accept the current key and the previous one simultaneously. During a rotation both are live. Tokens minted just before the switch keep working while tokens minted after it use the new key, so there is no instant at which valid tokens are refused.

Rotation therefore runs: publish the new key, accept both, drop the old key. The overlap window is comfortably longer than the 1-hour config cache plus the 600-second (10-minute) token lifetime, so a client that refreshes on schedule never observes the change.


The client's side of the contract#

Cache-Control: public, max-age=3600

Cache /wp-config for up to an hour, then refetch. That is the entire obligation.

python
# Correct: fetched, cached with a TTL, refreshed on expiry
_cache = {"config": None, "expires_at": 0}

def get_wp_config():
    now = time.time()
    if _cache["config"] and now < _cache["expires_at"]:
        return _cache["config"]

    resp = requests.get(
        "https://api.worthypayments.com/v1/wp-config",
        headers={"x-api-key": PUBLISHABLE_KEY},
        timeout=5,
    )
    resp.raise_for_status()

    _cache["config"] = resp.json()
    _cache["expires_at"] = now + 3600
    return _cache["config"]
python
# Wrong: this breaks at the next rotation, for every card at once
WP_PUBLIC_KEY = "BFnYeY4F9PJP1DbNn9eWHc0g…"
WP_KID = 1

Take kid, public_key and info from the same response, together. Mixing a cached public_key with a freshly fetched kid produces tokens that cannot be opened.

A 503 wp_config_unavailable must not be cached and must not evict a good cached value. Retry it; the key is not being served at that moment.

Do not cache across a deploy boundary and call it done. A long-lived process that fetches once at boot is a hardcoded key with extra steps. The TTL has to be real.


If a rotation ever does catch you#

You get wp_token_malformed, a 400.

There is no dedicated "unknown key" code, and that is deliberate: kid sits inside the sealed envelope, so a retired key is indistinguishable from a corrupt or tampered token. Reporting it separately would leak which of those happened and turn the endpoint into a card-checking oracle. See A note on retired keys.

Recovery is the same either way: refetch /wp-config, seal a new token, charge again.

js
if (err.code === 'wp_token_malformed') {
  await refetchWpConfig();                 // bypass the cache
  const token = await sealCard(card);
  result = await postCharge({ token, amount, idempotencyKey });
}

Warning: Retry once and only once. wp_token_malformed normally means a genuinely bad token, so an unconditional loop hammers us with requests that can never succeed. One retry costs nothing and covers the rotation case; a loop is a bug. If it recurs, your config is pinned somewhere and no number of retries will help.

Handle it even though it should never fire. It is the difference between a rotation costing you nothing and a rotation costing you a checkout outage.


Format versions are a different thing#

Key rotation changes the key. It does not change the format.

The token prefix wp:v1: names the format: grammar, envelope schema, cipher suite. A new prefix (wp:v2:) would mean a new cipher suite, and v1 would keep being accepted through a long deprecation. That is an announced migration with a timeline, not a silent background operation.

alg in /wp-config names the suite explicitly:

HPKE-P256-HKDF-SHA256-AES256GCM

Check this string and fail if you do not recognize it. Treat it as required, not as "validate if present": a response that omits alg would otherwise skip the check entirely, and the response is the one thing an attacker positioned to answer your fetch controls. Encrypting with the wrong suite produces a token we cannot open, answered with the deliberately uninformative wp_token_malformed. Failing loudly at config-fetch time is far easier to debug than failing at charge time.


Checklist#

  • /wp-config is fetched at runtime, never read from source or env.
  • The response is cached with a real TTL of at most 1 hour.
  • kid, public_key and info all come from the same response.
  • alg is checked, and an unrecognized value fails loudly.
  • wp_token_malformed triggers one refetch-and-retry. Once, never a loop.
  • No process holds a config in memory for longer than its TTL.