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: EdDSA with 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 kid only within that trusted set, and reject a missing or unknown kid.
  • Require exp and nbf and check them, with a small clock-skew allowance (60 seconds in the examples).
  • Require iss to equal your configured issuer, and aud to 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 new gen. 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:

  • typ is license-status+jwt and aud is license-status. These keep a status token from passing as a license key, and the other way round, so check both.
  • sub equals your key's sub, and product equals your product id.
  • key_hash equals the base64url SHA-256 of the key you sent (or replacement_key_hash matches the new key, see below).
  • nonce equals the nonce you sent. A fresh random nonce per check stops someone from replaying an older answer.
  • exp hasn't passed. Keep the iat of the newest status token you have applied, and ignore any token with an older iat.

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:

  1. Verify the new key with the normal key rules.
  2. Check that its sub equals the old key's sub, and that its base64url SHA-256 equals replacement_key_hash.
  3. 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
Last updated October 28, 2026