License Keys
A license key is a signed token that your software can verify offline. Breakwater builds it from a customer's license, so the key always reflects what the license says right now: who it is for, which product, and the dates it is valid. You can also attach your own claims, such as seat counts or feature flags.
Breakwater only signs facts. It does not enforce anything inside your application. Your code verifies the signature with your public key and decides what to do with the result.
What a key contains
A key is a JWT signed with EdDSA (Ed25519). Here is a decoded example payload:
{
"iss": "https://app.breakwaterapp.com/vendors/acme",
"sub": "lic_ghi789",
"aud": "prod_abc123",
"iat": 1760000000,
"nbf": 1767225600,
"exp": 1798761600,
"gen": 1,
"customer": {"id": "cust_def456", "name": "Globex Corp"},
"product": {"id": "prod_abc123", "slug": "acme-server"},
"tag_pattern": ">=1.0,<2.0",
"claims": {"seats": 25, "features": ["sso", "audit-log"]}
}
| Claim | Meaning |
|---|---|
iss |
Your issuer URL. Your verifier checks it against a fixed value. |
sub |
The license id. |
aud |
The product id (not the slug, which you can edit). |
iat |
When this key was generated. |
nbf / exp |
The license start and expiry dates. |
gen |
The license's key generation. Revoking a license's keys moves it to the next generation (see Revocation and status checks). Keys issued before generations existed have no gen; treat that as 1. |
customer, product |
Who and what the license is for. |
tag_pattern |
The license's tag pattern, or null. |
claims |
Your own claims, exactly as you set them on the license. |
The header carries alg: "EdDSA" and a kid that identifies the signing key.
Keys are generated each time they are requested and are not stored. Two downloads of the same license differ only in iat, until the license changes or its keys are revoked. Active and pending licenses get a key, with nbf set to the license start date. Cancelled and expired licenses do not.
Where to get keys
Vendors: open a license in the vendor portal and use the License key card to show or download the key, revoke the license's existing keys, or download a signed status for an air-gapped install. You can set custom claims on the license form. The Signing key page in the sidebar shows your key id, issuer, JWKS URL, status check URL and public key. Through the API, use GET /api/v1/vendor/customers/{customer_id}/licenses/{id}/key for a key, POST /api/v1/vendor/customers/{customer_id}/licenses/{id}/revoke_keys to revoke existing keys, and GET /api/v1/vendor/signing_key for the public key. License create and update accept claims.
Customers: the license page in the customer portal has a License key card with the same show and download options.
Every vendor gets a signing key automatically the first time one is needed. Issuing a key and creating a signing key both appear in your audit log.
Issuer and public key
The issuer is your vendor URL:
https://app.breakwaterapp.com/vendors/acme
Your public keys are published as a JWKS at the issuer URL plus /jwks.json:
https://app.breakwaterapp.com/vendors/acme/jwks.json
The endpoint is public and needs no authentication. Your software can fetch it, or you can embed the public key for installs that have no network access. Copy the key from the Signing key page (or the API) and ship it with your application. When you embed a key, keep its kid so your verifier can still look it up.
Verification contract
Every example below follows these rules. If you write your own verifier, follow them too.
- Allow only
alg: EdDSAwith an Ed25519 key. - Get keys from your configured JWKS URL or an embedded public key. Never use anything the token supplies (
jku,jwk,x5u). - Look up
kidonly within that trusted set, and reject a missing or unknownkid. - Require
expandnbfand check them, with a small clock-skew allowance (60 seconds in the examples). - Require
issto equal your configured issuer, andaudto equal your own product id.
Always check aud. One signing key covers all of your products, so without the aud check a key issued for product A would also unlock product B.
Tag patterns
tag_pattern is the same pattern the registry uses to decide which image tags a customer can pull (see Tag Patterns). It is informational in the key. Breakwater does not interpret it inside your app, so if you want your software to honor a version restriction, you compare your own version against it.
Revocation and status checks
A key always verifies offline until its exp, so it keeps working in installs that have no network access. To find out whether a key has been revoked, an app that can reach Breakwater asks for its status. Breakwater answers with a status token: a short JWT signed with the same key, which your app verifies the same way. Apps that never check behave exactly as before.
What revokes keys
- Cancelling a license revokes all of its keys. If you reactivate it later, its current keys pass again.
- Expiry revokes all keys, once the license's status is expired or its expiry date has passed.
- Deleting a license revokes all of its keys.
- Revoke existing keys (on the license page, or
POST .../licenses/{id}/revoke_keys) moves the license to its next key generation. Keys from earlier generations stop passing, and new keys carry the newgen. Use it when a key leaks, when you downgrade a license and don't want the old claims honored, or before reactivating a cancelled license whose old keys should stay dead. The customer then gets a new key from their portal, or you hand one over through the API. Keys from an earlier generation are never refreshed (see Refreshing keys), so a leaked key can't be traded for a new one.
Checking a key
POST the key to your status check URL, which is your issuer URL plus /license_status:
POST https://app.breakwaterapp.com/vendors/acme/license_status
Content-Type: application/json
{"license_key": "<the key>", "nonce": "<16 to 128 random base64url characters>"}
The endpoint is public; the key itself is the credential. Breakwater only answers for keys it signed for you. Anything else gets 400 {"error": "invalid_key"}, and an unknown vendor looks the same.
A successful response is:
{
"status_token": "<jwt>",
"license_key": "<a newer key, only when there is one>"
}
Check at startup and then every 1 to 24 hours, with some random jitter so a fleet of installs doesn't call at the same moment. Checks are limited to 60 a minute per IP address. A 429 response carries Retry-After; treat it like an unreachable server.
Verifying the status token
A decoded status token looks like this:
{
"iss": "https://app.breakwaterapp.com/vendors/acme",
"aud": "license-status",
"sub": "lic_ghi789",
"product": "prod_abc123",
"iat": 1760000000,
"nbf": 1760000000,
"exp": 1760604800,
"status": "valid",
"reason": null,
"min_gen": 1,
"key_hash": "<SHA-256 of the key you sent, base64url>",
"nonce": "<the nonce you sent>"
}
Its header has typ: "license-status+jwt". Verify it with the same keys and rules as a license key, and also require:
typislicense-status+jwtandaudislicense-status. These keep a status token from passing as a license key, and the other way round, so check both.subequals your key'ssub, andproductequals your product id.key_hashequals the base64url SHA-256 of the key you sent (orreplacement_key_hashmatches the new key, see below).nonceequals the nonce you sent. A fresh random nonce per check stops someone from replaying an older answer.exphasn't passed. Keep theiatof the newest status token you have applied, and ignore any token with an olderiat.
Only the signed status inside the token counts. The response has no unsigned status field.
Acting on the answer
Treat the key as revoked when status is revoked, or when the key's gen (missing counts as 1) is lower than min_gen. reason says why: cancelled, expired, superseded (an earlier generation, or the license now covers another product), not_found (the license was deleted) or key_expired (see below). What revoked means in your app is up to you: read-only mode, a warning, or refusing to start.
A valid status token is good until its exp, 7 days after it was issued. If Breakwater can't be reached, keep using the last valid status token until then, and after that decide for yourself whether to keep running on the key alone. This means someone who blocks the check delays a revocation by up to 7 days. Daily checks normally pick up a revocation within a day.
Refreshing keys and the 30-day window
When the key you send is still good but out of date, for example because you renewed the license, changed its claims or dates, or it was issued before key generations existed, the response also carries license_key, the license's current key, and the status token carries its hash in replacement_key_hash. Your app should:
- Verify the new key with the normal key rules.
- Check that its
subequals the old key'ssub, and that its base64url SHA-256 equalsreplacement_key_hash. - Store the new key in place of the old one, together with the status token, and use the new key from then on.
Expired keys can still be refreshed for 30 days. A key whose own exp passed less than 30 days ago still gets an answer, and if the license has been renewed and the key's generation is current, the answer is valid with a new key. That way an install that was switched off or offline across a renewal recovers by itself. So when your app finds its key expired, it should still send the key to the status check (after checking the signature, issuer and audience, but not exp) before giving up. If the key expired more than 30 days ago, the answer is revoked with reason key_expired and no new key; the customer has to paste in a new key from their portal.
Refreshing doesn't need anything but a key. Anyone holding a valid key, or one that expired less than 30 days ago, can keep fetching the current key for as long as the license is active. That gives no more access than the key already did, and Revoke existing keys stops it: keys from an earlier generation are never refreshed.
Each refresh appears in your audit log as license_key.issued (at most once a day for the same license and key contents). Plain checks are not logged.
Air-gapped installs
Installs with no network access never check, so their keys work until exp. Keep license terms short for these customers if revocation matters to you, because exp comes straight from the license.
If your app supports it, an operator can also import a status by hand. Download status on the license page gives you a status token for the license as it stands, without key_hash or nonce. It stays usable until the license's expiry date, and for at least 7 days. An app applies it with the same rules: sub and product must match its key, newest iat wins, and a revoked status or a min_gen above the key's gen revokes the key. A hand-carried valid status never extends a key; it only counts as the newest status, for example to undo an earlier revocation after you reactivate a license. Importing is cooperative: an operator who doesn't import a revocation isn't revoked, which is the limit of any offline check.
Examples
Ruby
This uses only the Ruby standard library (OpenSSL). Fetch the JWKS yourself, or build the hash from an embedded key.
require "base64"
require "json"
require "openssl"
module LicenseKeyVerifier
class Invalid < StandardError; end
def self.verify(token, jwks:, issuer:, audience:, now: Time.now, leeway: 60)
parts = token.to_s.split(".", -1)
raise Invalid, "token must have three segments" unless parts.length == 3
header_b64, payload_b64, signature_b64 = parts
header = decode_json(header_b64)
raise Invalid, "unsupported alg" unless header["alg"] == "EdDSA"
kid = header["kid"]
raise Invalid, "missing kid" unless kid.is_a?(String) && !kid.empty?
jwk = Array(jwks["keys"]).find { |k| k.is_a?(Hash) && k["kid"] == kid }
raise Invalid, "unknown kid" unless jwk
raise Invalid, "key is not Ed25519" unless jwk["kty"] == "OKP" && jwk["crv"] == "Ed25519"
public_key = OpenSSL::PKey.new_raw_public_key("ED25519", decode(jwk["x"]))
signature = decode(signature_b64)
unless public_key.verify(nil, signature, "#{header_b64}.#{payload_b64}")
raise Invalid, "bad signature"
end
payload = decode_json(payload_b64)
now = now.to_i
exp = payload["exp"]
nbf = payload["nbf"]
raise Invalid, "exp must be numeric" unless exp.is_a?(Numeric)
raise Invalid, "nbf must be numeric" unless nbf.is_a?(Numeric)
raise Invalid, "token has expired" if now > exp + leeway
raise Invalid, "token is not yet valid" if now < nbf - leeway
raise Invalid, "wrong issuer" unless payload["iss"] == issuer
raise Invalid, "wrong audience" unless payload["aud"] == audience
payload
rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError, JSON::ParserError => e
raise Invalid, "malformed token: #{e.class}"
end
def self.decode(segment)
raise Invalid, "missing segment" unless segment.is_a?(String)
Base64.urlsafe_decode64(segment)
end
def self.decode_json(segment)
value = JSON.parse(decode(segment))
raise Invalid, "segment is not a JSON object" unless value.is_a?(Hash)
value
end
end
require "net/http"
jwks = JSON.parse(Net::HTTP.get(URI("https://app.breakwaterapp.com/vendors/acme/jwks.json")))
payload = LicenseKeyVerifier.verify(
token,
jwks: jwks,
issuer: "https://app.breakwaterapp.com/vendors/acme",
audience: "prod_abc123"
)
puts payload["claims"]
Go
Uses github.com/golang-jwt/jwt/v5. The keys map holds the trusted public keys by kid, taken from the JWKS x value (base64url, 32 bytes).
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
func verify(token string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
kid, _ := t.Header["kid"].(string)
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("unknown kid")
}
return key, nil
},
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithIssuer("https://app.breakwaterapp.com/vendors/acme"),
jwt.WithAudience("prod_abc123"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(60*time.Second),
)
if err != nil || !parsed.Valid {
return nil, fmt.Errorf("invalid license key: %w", err)
}
if nbf, err := claims.GetNotBefore(); err != nil || nbf == nil {
return nil, fmt.Errorf("license key has no nbf")
}
return claims, nil
}
// Build a trusted key from a JWKS "x" value.
func keyFromX(x string) (ed25519.PublicKey, error) {
raw, err := base64.RawURLEncoding.DecodeString(x)
if err != nil || len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bad public key")
}
return ed25519.PublicKey(raw), nil
}
The library validates nbf when it is present. The explicit check above rejects a key that has none.
Node
Uses jose (v5 or later).
import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
const issuer = "https://app.breakwaterapp.com/vendors/acme";
const jwks = createRemoteJWKSet(new URL(`${issuer}/jwks.json`));
export async function verify(token) {
// jose would accept a token without kid if the set has one key; we don't.
const { kid } = decodeProtectedHeader(token);
if (typeof kid !== "string" || !kid) throw new Error("missing kid");
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience: "prod_abc123",
algorithms: ["EdDSA"],
requiredClaims: ["exp", "nbf"],
clockTolerance: 60,
});
return payload;
}
Python
Uses PyJWT 2.10.1 or later with the cryptography package (pip install "pyjwt[crypto]>=2.10.1"). Earlier versions, including 2.10.0, have a partial issuer match flaw (CVE-2024-53861).
import jwt
from jwt import PyJWKClient
ISSUER = "https://app.breakwaterapp.com/vendors/acme"
jwks_client = PyJWKClient(f"{ISSUER}/jwks.json")
def verify(token):
signing_key = jwks_client.get_signing_key_from_jwt(token) # looks up kid in the JWKS
return jwt.decode(
token,
signing_key.key,
algorithms=["EdDSA"],
issuer=ISSUER,
audience="prod_abc123",
leeway=60,
options={"require": ["exp", "nbf", "iss", "aud"]},
)
Checking status (Ruby)
This verifies a status token with the Ruby standard library, in the same style as the key verifier above. verify raises Invalid for anything that fails a rule; revoked? applies the revocation rule to a verified status and the verified key payload.
require "base64"
require "json"
require "openssl"
module LicenseStatusVerifier
class Invalid < StandardError; end
# +license_key+ is the key that was sent; pass replacement: true when
# checking the status against a key that came back in the same response.
# Leave license_key nil for a hand-carried status, which has no key_hash.
def self.verify(token, jwks:, issuer:, product:, sub:, license_key: nil, replacement: false, nonce: nil, now: Time.now, leeway: 60)
parts = token.to_s.split(".", -1)
raise Invalid, "token must have three segments" unless parts.length == 3
header_b64, payload_b64, signature_b64 = parts
header = decode_json(header_b64)
raise Invalid, "unsupported alg" unless header["alg"] == "EdDSA"
raise Invalid, "not a status token" unless header["typ"] == "license-status+jwt"
kid = header["kid"]
raise Invalid, "missing kid" unless kid.is_a?(String) && !kid.empty?
jwk = Array(jwks["keys"]).find { |k| k.is_a?(Hash) && k["kid"] == kid }
raise Invalid, "unknown kid" unless jwk
raise Invalid, "key is not Ed25519" unless jwk["kty"] == "OKP" && jwk["crv"] == "Ed25519"
public_key = OpenSSL::PKey.new_raw_public_key("ED25519", decode(jwk["x"]))
unless public_key.verify(nil, decode(signature_b64), "#{header_b64}.#{payload_b64}")
raise Invalid, "bad signature"
end
status = decode_json(payload_b64)
now = now.to_i
exp = status["exp"]
nbf = status["nbf"]
raise Invalid, "exp must be numeric" unless exp.is_a?(Numeric)
raise Invalid, "nbf must be numeric" unless nbf.is_a?(Numeric)
raise Invalid, "status has expired" if now > exp + leeway
raise Invalid, "status is not yet valid" if now < nbf - leeway
raise Invalid, "wrong issuer" unless status["iss"] == issuer
raise Invalid, "wrong audience" unless status["aud"] == "license-status"
raise Invalid, "wrong license" unless status["sub"] == sub
raise Invalid, "wrong product" unless status["product"] == product
raise Invalid, "unknown status" unless %w[valid revoked].include?(status["status"])
if license_key
claim = replacement ? "replacement_key_hash" : "key_hash"
raise Invalid, "status is for another key" unless status[claim] == hash_key(license_key)
end
raise Invalid, "nonce mismatch" if nonce && status["nonce"] != nonce
status
rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError, JSON::ParserError => e
raise Invalid, "malformed token: #{e.class}"
end
# Whether a verified status revokes the key with this (verified) payload:
# revoked outright, or the key is from a generation below min_gen.
def self.revoked?(status, key_payload)
return true if status["status"] == "revoked"
min_gen = status["min_gen"]
min_gen.is_a?(Integer) && (key_payload["gen"] || 1) < min_gen
end
def self.hash_key(license_key)
Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(license_key), padding: false)
end
def self.decode(segment)
raise Invalid, "missing segment" unless segment.is_a?(String)
Base64.urlsafe_decode64(segment)
end
def self.decode_json(segment)
value = JSON.parse(decode(segment))
raise Invalid, "segment is not a JSON object" unless value.is_a?(Hash)
value
end
end
A status check with it, using the LicenseKeyVerifier above for the key:
require "net/http"
require "securerandom"
ISSUER = "https://app.breakwaterapp.com/vendors/acme"
PRODUCT = "prod_abc123"
REFRESH_LEEWAY = 30 * 24 * 60 * 60
def check_status(key, jwks)
# A key that expired less than 30 days ago can still be refreshed, so the
# status check alone allows that much leeway. Never start on it this way.
key_payload = LicenseKeyVerifier.verify(key, jwks: jwks, issuer: ISSUER, audience: PRODUCT, leeway: REFRESH_LEEWAY)
nonce = SecureRandom.urlsafe_base64(24)
response = Net::HTTP.post(URI("#{ISSUER}/license_status"), {license_key: key, nonce: nonce}.to_json,
"Content-Type" => "application/json")
return :unreachable unless response.is_a?(Net::HTTPOK)
body = JSON.parse(response.body)
new_key = body["license_key"]
status = LicenseStatusVerifier.verify(body["status_token"], jwks: jwks, issuer: ISSUER, product: PRODUCT,
sub: key_payload["sub"], license_key: new_key || key, replacement: !new_key.nil?, nonce: nonce)
if new_key
new_payload = LicenseKeyVerifier.verify(new_key, jwks: jwks, issuer: ISSUER, audience: PRODUCT)
raise LicenseStatusVerifier::Invalid, "replacement is for another license" unless new_payload["sub"] == key_payload["sub"]
# store new_key and body["status_token"] together, and use new_key from now on
key_payload = new_payload
end
LicenseStatusVerifier.revoked?(status, key_payload) ? :revoked : :valid
end