Issuing a license
This is the vendor side of licensing. That page describes installing a key. This one describes producing one.
An air gapped installation that mints its own licenses against its own signing key follows the same steps.
The tool
Section titled “The tool”Issuing is tools/licensegen, a command line program with no caller, run by hand
by a person holding a signing key. Wrapping it in a workflow would put the
signing key somewhere a workflow can reach.
Before anything: three things that are not true yet
Section titled “Before anything: three things that are not true yet”No released binary carries a signing key. ee/engine/license/keys.go
expects a release to stamp public keys into trustedKeys with a linker flag.
Nothing does. tools/release/build.sh builds the community engine and stamps
the version, the commit and the build date, and it does not build the
enterprise binary at all. So a key signed today verifies only where
AF_LICENSE_PUBLIC_KEYS supplies the public half, which is the air gapped
arrangement. Issuing to a customer running a downloaded binary is not possible
until an enterprise release exists.
Nothing records what was issued. No ledger, no database row, no file. The only record of a license is the customer’s copy and whatever the person who signed it wrote down. Every reissue and every support question depends on that.
There is no revocation list. Verifier.Revoke exists, has no caller
outside its own test, and nothing loads a list of withdrawn identifiers. See
withdrawing a license for what is actually available.
Step one: the signing key
Section titled “Step one: the signing key”Once per key, not once per license.
go run ./tools/licensegen keygen -id 2026-09It prints a key id, a public key and a private key, and writes nothing to disk. Paste the private half into the key vault immediately and nowhere else. The program has no way to recover it.
The key id is a label you choose. Date it.
The public half goes to the verifier as kid=base64, and the key id in that
pair has to be the one you just chose. Keep every previous entry: a build that
trusts only the newest key cannot verify a license already in the field.
export AF_LICENSE_PUBLIC_KEYS=2026-09=gxvgko3UB27tcxm07XOfJrEDRcAbLzmdtbnCOjEp9ywStandard and URL safe base64 are both accepted, so a key pasted out of whatever tool produced it works either way.
Step two: the request
Section titled “Step two: the request”A JSON file describing what was bought.
{ "org": "acme", "plan": "enterprise", "features": ["sso", "scim"], "seats": 25, "months": 12}| Field | Meaning |
|---|---|
org |
The organization slug. Required, and inside the signature, so a key cannot be edited to name a different one. It must equal the customer’s AF_ORG exactly, ignoring case and surrounding space. |
plan |
Display only. Nothing branches on it. |
features |
What the license permits, from the closed set below. Refused if it names anything else. |
seats |
The member limit. Required, and zero means unlimited, so it has to be written rather than omitted. |
months |
How long from the issue time. Defaults to 12. |
grace_days |
How long after expiry features keep working. Defaults to 14. |
trial |
Marks an evaluation license, which shows a banner. |
The features are air_gapped, audit_stream, billing, cloud_database,
cloud_runtime, compliance_packs, enterprise_dashboard,
enterprise_secrets, multi_runtime, policy_enforcement, rbac, scim,
sso and support_access. Anything else
is refused at issue time. The verifier carries an unknown name without acting on
it, because a license issued for a newer release names features an older binary
has never heard of, so the generator is the only place the set can be closed.
No feature is issued with a warning any more
Section titled “No feature is issued with a warning any more”The generator used to print a warning beside a key naming rbac, and before that
air_gapped. Both were real capabilities the license did not grant: air gapped
operation was a property every installation had, and the custom roles library
was written and tested with nothing storing a role model. A renewal conversation
that treated either as a thing being bought would have been a conversation about
nothing, and the warning put that sentence in front of whoever issued the key.
Both are now gated. air_gapped left when the mode that refuses was built.
rbac left on 2026-09-11, when the enterprise control plane gained a stored
role model, routes to define one, and a resolver that asks the license and the
organization’s entitlement before a custom role widens anything. With nothing
left for it to name, the warning was deleted rather than kept as a line that can
never print. If a feature is ever built and deliberately gated nowhere again,
ee/engine/license/license.go says how to bring the warning back.
Features that cannot be issued
Section titled “Features that cannot be issued”billing and enterprise_dashboard are in the set above, and a request naming
either of them is refused. Nothing in this product enforces them, so a license
carrying one would verify, report itself active, print the feature in
af license status, and change nothing about what the software does.
Both refusals are in the product rather than in a checklist. The generator will
not sign one, and the verifier carries the name and never permits it, exactly as
it treats a feature from a release the binary predates. When either capability
is built, one entry is removed from notShipped in
ee/engine/license/license.go and both refusals lift together.
Step three: sign it
Section titled “Step three: sign it”The private key arrives in the environment from the vault, for the length of one command, and is never read from a file in this repository.
AF_LICENSE_SIGNING_KEY=$(vault-read antifailure/license/2026-09) \ go run ./tools/licensegen issue \ -request ./acme.json \ -key-id 2026-09 \ -id lic-0001-id is the license identifier. Nothing generates it and nothing checks it is
unique, so pick a scheme and keep to it.
The key goes to standard output on one line. A receipt goes to standard error:
signed lic-0001 for acme: 25 seats, 12 months, features sso, scimkey id 2026-09 must name this public key in the verifier: gxvgko3UB27tcxm07XOfJrEDRcAbLzmdtbnCOjEp9ywCheck the second line before you send anything. -key-id is a label this
program cannot verify against the key it signed with. Sign with one key and
label it as another and the customer’s engine looks the label up, finds a
different public key, and reports the license as tampered with. The licensing
page tells them that almost always means the token was truncated in transit, so
a typo here sends everybody hunting for a paste error that never happened.
Comparing that line against the entry in the verifier’s key list is the only
thing that catches it.
Step four: what the customer does
Section titled “Step four: what the customer does”Two environment variables, and nothing is stored.
export AF_LICENSE_KEY=aflic_eyJleHBpcmVzX2F0IjoiMjAyNy0wOS0wMl...export AF_ORG=acmeaf license status This is the enterprise edition, licensed to acme.
Licensed to acme on the enterprise plan Expires 2 September 2027
Features: scim ssoAsk them to send that output back. It is the only confirmation available that the key they received is the key you signed.
af license inspect does not exist. To read a key during a support call, use
the generator, which decodes without verifying and says so:
go run ./tools/licensegen inspect -token "$AF_LICENSE_KEY"Step five: reissuing
Section titled “Step five: reissuing”There is no renewal. Sign a new key from a new request and send it, and the customer replaces the variable. The old key stays valid until its own expiry, which is why a shortened reissue does not shorten anything.
An expired license enters the grace period and then falls back to the community behaviour; see expiry and grace.
Withdrawing a license
Section titled “Withdrawing a license”The verifier has a Revoke method and a revoked state. Nothing calls it and
nothing loads a list of withdrawn identifiers, so a revoked state cannot be
reached by any shipped binary. Marking a license as revoked is not something
you can currently do.
What is available:
- Let it expire. The reason grace periods and short terms exist. A twelve month license issued to somebody who should not have it is a twelve month problem, so keep terms short where the relationship is uncertain.
- Rotate the signing key. Removing a public key from the verifier’s list invalidates every license signed with it, not one. That is the blunt instrument, and it means reissuing to every other customer on that key first.
- Ask. For a self hosted installation the key sits in the customer’s environment and only they can remove it. Offline verification means there is no other lever, and that is the price of a license that keeps working when the network does not.
The hosted control plane’s signing key
Section titled “The hosted control plane’s signing key”The hosted control plane is the vendor’s own installation, and it licenses
itself with one signing key, license-signing-key-hosted-2026-09, key id
hosted-2026-09. Its public half is license_public_keys in both
staging.tfvars and production.tfvars, and it signs both environments’
licences. Turning on the enterprise edition
has the command that uses it.
It is kept in afcp-kv-centralus, the staging control plane’s own vault,
which contradicts the advice above that a signing key lives somewhere no pipeline
can reach. What that costs, read from the vault’s role assignments on 2026-09-12:
| Principal | Role on the vault | What it means for this key |
|---|---|---|
afcp-id, the managed identity |
Key Vault Secrets User | The staging app, its bootstrap job and its maintenance job can read it. Nothing in the control plane does, but anybody who runs code as that identity can. |
| the operator who runs Terraform | Key Vault Secrets Officer | Expected: this is the person who issues the licence. |
af-infra-ci, the GitHub Actions identity |
Key Vault Secrets Officer | Its federated credentials include pull_request and ref:refs/heads/main as well as both environments, so a workflow run on a pull request from a branch of this repository can read the key. ci.tf records that grant as made by hand on 2026-08-28. Pull requests from forks receive no identity token and cannot. |
So the key is exactly as safe as the staging app identity and this repository’s workflows, and the consequence of either being compromised is that somebody can mint a licence both hosted environments accept. It grants nothing on any customer’s self hosted installation, which trusts only the keys its own operator supplies.
Moving it means a vault that holds nothing else and grants a data role to one
person, then the same procedure with a new key id: keygen, add the new public
half beside the old one in both tfvars files, reissue both licences, and remove
hosted-2026-09 only after both environments have started on the new ones.
What goes wrong, and what the customer sees
Section titled “What goes wrong, and what the customer sees”| They see | Cause |
|---|---|
no licence signing keys, so no licence can be verified |
The binary carries no stamped keys, which is every build today. Set AF_LICENSE_PUBLIC_KEYS. |
| The license key’s signature does not verify | Usually a truncated paste. Otherwise -key-id named a key that is not the one that signed. |
| Signed by a key this build does not know | The key id is not in the verifier’s list, or a rotation dropped it. |
| Issued to one organization, installed at another | AF_ORG does not match the request’s org. |
| Active, and no features | The request named features that were signed and are not permitted, or named none at all. |
| Seats all in use with room to spare | seats was omitted before it was required, or set from the wrong line of the order. |
Related: licensing, compliance.