Skip to content

Signing keys and rotation

Each merchant has an Ed25519 signing keypair. The service signs every offline token with the private key. Your shipped product embeds the matching public key and verifies tokens offline against it. The public key is not secret: it is shown on the Integration page and is safe to ship in your binary.

If the private key and the key baked into a shipped build ever diverge, activation succeeds on the server but the product rejects the signed token it gets back, showing a generic error. The device looks activated everywhere except the one place that matters.

Guard against drift automatically with the build-time key check.

GET /v1/products/{productId}/pubkey is public and read-only:

{ "productId": "…", "publicKeyRawB64": "<active key>", "publicKeys": ["<active key>", "<keys still verifying live leases>"] }

publicKeyRawB64 is always the active signing key. publicKeys is the full set the service may currently sign with or still has valid leases under, active first. Bake the whole set during a rotation window.

Every token carries a kid: a short ID derived from the signing key (first 16 base64url characters of the SHA-256 of the raw key). A build can hold several keys and use kid to pick the right one. kid is a routing hint, never a trust decision: a forged kid can only select the wrong key, and the signature then fails.

Rotation is carried out by the service operator at the merchant’s request. Your part is the order of operations, which matters more than any code:

  1. Ship acceptance first. Release a build whose keyset contains both the current key and the incoming one. In the SDK, pass an array:

    evaluateOffline({ publicKeyRawB64: [CURRENT_KEY, NEXT_KEY] /* …other fields */ });

    In the JUCE kit, set cfg.publicKeyB64 to the current key and add the next one with cfg.publicKeysB64.add(...). Nothing changes for users yet.

  2. Wait for that build to reach the field. This is the long pole. Measure it (installed-base telemetry, download counts, support signal) and treat it as the gate, not the plan. The SDK and kit report the keys they accept on activate and heartbeat so the operator can see adoption.

  3. Then the operator switches signing to the new key. New tokens carry the new kid; builds from step 1 accept them, and every token already issued under the old key stays valid until its lease lapses.

  4. Retire the old key only once no supported build depends on it and no live lease still carries its kid. A lease lives for its term plus grace: 44 days on defaults, longer for products on the deactivate seat policy (up to the product’s longLeaseDays plus grace).

Doing step 3 before step 1 is the failure mode: every installed copy rejects every new token with unknown-key, and the only fix is another release cycle.