Manage public RPC API keys
SkillCloud & infraManage public RPC API key consumers for an Aztec RPC environment (mainnet or testnet), add a new consumer end-to-end (mint the GCP Secret Manager secret, wire it into Kong via Terraform, apply, verify, open the PR), list existing consumers and their real owners, and check or change a consumer's per-minute rate limit. Use when asked to add/create an RPC API key, onboard a new RPC client/consumer, "give <someone> a mainnet/testnet rpc key", or to list/inspect/raise/lower an RPC consumer's rate limit.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Manage public RPC API keys skill
What this skill tells your AI
The instructions your AI receives, as published by aztec-labs-eng/aztec-node in spartan/.claude/skills/create-rpc-api-key/SKILL.md and read by ahel’s review.
This runbook covers the full lifecycle of a public RPC gateway consumer: creating a key (Steps 1–6), listing who has keys, and checking / changing a consumer's rate limit.
A key is a value stored in GCP Secret Manager; Terraform turns it into a Kong consumer that can call every keyed route in the environment. Externally the consumer is just clientN; the real recipient is recorded only as a private client_name annotation on the secret.
The two RPC environments live in spartan/terraform/deploy-rpc/environments/:
| Environment | dir | secret prefix | namespace / release prefix |
|---|---|---|---|
| mainnet | environments/mainnet | mainnet-rpc-consumer | mainnet-rpc / mainnet |
| testnet | environments/testnet | testnet-rpc-consumer | testnet-rpc / testnet |
The examples below use mainnet. For testnet, swap the dir, prefix, namespace, and context. Always read the target env's existing consumer entries and copy their exact prefix rather than assuming.
Two consumer-config shapes (mainnet vs testnet differ)
The CONSUMERS map passed to the environment module is written differently in each env — read the target env's main.tf before editing:
- mainnet — an explicit map, one
clientNblock per consumer, each with its ownrate_limit_minute. This is where you set an individual mainnet consumer's limit. - testnet — generated from a
consumer_secret_nameslist via aforcomprehension that hardcodesrate_limit_minute = 0for every entry. So every testnet key is unlimited by construction, and there is no per-consumer limit knob unless you break a consumer out of the loop into its own explicit entry.
Rate-limit tiers. rate_limit_minute = 0 means unlimited (no rate-limit plugin is generated; see the consumers_with_rate_limit filter in spartan/terraform/modules/rpc-gateway/main.tf). A positive value generates a rate-limiting KongPlugin with policy = "local" and limit_by = "consumer" — local means the limit is enforced per Kong pod, so the effective ceiling is rate_limit_minute × (number of Kong gateway pods). When ALLOW_ANONYMOUS = true (testnet), keyless callers fall back to an anonymous consumer capped at ANONYMOUS_RATE_LIMIT_MINUTE (default 300) per IP, per pod; mainnet sets ALLOW_ANONYMOUS = false so a key is mandatory there.
Before you start — access and tooling
- Working directory: agents in this repo often run with
yarn-projectas the shell CWD, but the script and Terraform live under the git root'sspartan/. Use absolute paths (or the correct git-root-relative path). Do notcd— the Bash working directory persists across calls and a straycdwill break later relative paths. - gcloud: authenticated, project
testnet-440309(gcloud config get-value project). Terraform's GCS backend and google provider need Application Default Credentials — if you hit auth errors,gcloud auth application-default login. - kubectl: the GKE context
gke_testnet-440309_us-west1-a_aztec-gke-publicmust exist (kubectl config get-contexts). - Terraform version: the remote state may have been written by a newer Terraform than your local binary, and Terraform refuses to operate on newer state. Check and match:
If local# state version (mainnet) gcloud storage cat gs://aztec-terraform/aztec-gke-public/mainnet-rpc/deploy-rpc/terraform.tfstate/default.tfstate | jq -r .terraform_version terraform version< state, install a matching-or-newer Terraform before continuing.
Step 1 — mint the secret
spartan/scripts/create_api_key.sh <prefix> <internal-owner-name>
<prefix>= the environment's consumer prefix, e.g.mainnet-rpc-consumer.<internal-owner-name>= a private annotation naming the real recipient (e.g.vitalik,fairies). Kept for our records only — do not put it in the public PR. Match the lowercase style of existing annotations.- The script lists existing
<prefix>-client*secrets, takes the most recently created, and creates the next index (clientN+1). If none exist it starts atclient1.
/abs/path/to/spartan/scripts/create_api_key.sh mainnet-rpc-consumer fairies
# -> Created version [1] of the secret [mainnet-rpc-consumer-client9].
Verify (note the index and annotation):
gcloud secrets describe mainnet-rpc-consumer-client9 --format="value(name.basename(), annotations)"
Caveat: the "next index" is derived from the most-recently-created secret, not the max number. This is only a problem if secrets were created out of order — normally fine.
Step 2 — add the consumer to Terraform
Edit spartan/terraform/deploy-rpc/environments/<env>/main.tf and append a clientN entry to the CONSUMERS map, matching the existing entries exactly:
client9 = {
username = "mainnet-rpc-consumer-client9"
gcp_secret_manager_secret_name = "mainnet-rpc-consumer-client9"
rate_limit_minute = 0
}
rate_limit_minute = 0 means unlimited (no rate-limit plugin is generated). Use a positive value only if a cap is intended.
testnet variant. testnet does not use an explicit map — it builds CONSUMERS from a consumer_secret_names list. Add the new secret name to that list instead:
consumer_secret_names = [
"testnet-rpc-consumer-client1",
"testnet-rpc-consumer-client2", # new
]
Every entry inherits rate_limit_minute = 0 (unlimited) from the comprehension. To give one testnet consumer a finite cap, it cannot stay in the uniform loop — break it out into its own explicit CONSUMERS entry (or merge an override) with the desired rate_limit_minute.
Step 3 — plan and verify (the guardrail)
TF=/abs/path/to/spartan/terraform/deploy-rpc/environments/mainnet
terraform -chdir="$TF" init -reconfigure -input=false
terraform -chdir="$TF" plan -input=false -out=tfplan
The plan must be exactly:
Plan: 2 to add, 0 to change, 0 to destroy.
The two additions are the new consumer's resources:
module.environment.module.rpc_gateway.kubernetes_manifest.consumer["clientN"]— the KongConsumermodule.environment.module.rpc_gateway.kubernetes_manifest.consumer_key_external_secret["clientN"]— the ExternalSecret that pulls the key from Secret Manager into Kong
If the plan shows anything else — a change/replace/destroy on an RPC node, helm release, domain, or another consumer — STOP and find out why. Common cause: a deployment change (e.g. a version promotion) has been merged to the branch but not yet applied to live, so applying now would also push that change. You do not need to pass image -vars if the env's image defaults match the live pods (kubectl -n <ns> get pods -o jsonpath=... <node>). If you must add a key without triggering such a pending deployment, temporarily revert that change in your working tree so the plan is clean — but do not commit the revert (see Step 6).
Step 4 — apply
terraform -chdir="$TF" apply -input=false tfplan
The ExternalSecret has a wait { Ready } block, so the GCP secret from Step 1 must already exist or the apply will block waiting for it to sync.
Step 5 — verify it is live
CTX=gke_testnet-440309_us-west1-a_aztec-gke-public
kubectl --context "$CTX" -n mainnet-rpc get kongconsumer mainnet-client9 # PROGRAMMED=True
kubectl --context "$CTX" -n mainnet-rpc get externalsecret mainnet-client9-rpc-key-auth # Ready/SecretSynced=True
(Resource names are <release-prefix>-clientN and <release-prefix>-clientN-rpc-key-auth.)
Step 6 — open the PR
The committed Terraform is a record of what is already applied. Keep the PR to a single-purpose, one-line addition.
- Commit only the
clientNaddition. If you made a temporary revert in Step 3 to get a clean plan, undo it so it is not committed — the diff must be theclientNblock and nothing else. Verify withgit diff <base>...HEAD -- <main.tf path>. - Commit message:
chore: add new client(the established convention). Author is the git author — no Claude attribution. - Base branch:
main. Branch off it, and if the base has moved, rebase the single commit onto it so the PR diff stays client-only.
git checkout -b stack/chore-add-new-client-N origin/main
# edit main.tf to add clientN
git add /abs/path/.../environments/mainnet/main.tf
git commit -m "chore: add new client"
git push -u origin stack/chore-add-new-client-N
gh pr create --base main --head stack/chore-add-new-client-N \
--title "chore: add new client" \
--body "Adds \`clientN\` to the mainnet RPC consumers. Secret created and already \`terraform apply\`-ed; the plan was the two expected resources (KongConsumer + ExternalSecret). Rate limit 0 = unlimited."
Print the PR URL back to the user. Do not name the private owner in the PR title or body.
If the previous client-add PR merged while you were working (and its branch was deleted), target main directly and rebase your commit onto it so the diff is only the new client.
Handing the key to the recipient
The key value was never printed (the script piped it straight into Secret Manager). Retrieve it with:
gcloud secrets versions access latest --secret=mainnet-rpc-consumer-client9
It is a bearer credential — share it over a secure channel, not plain chat/email. The recipient uses it either way:
- Path form:
https://canonical.mainnet.rpc.aztec-labs.com/<KEY> - Header form:
x-aztec-api-key: <KEY>againsthttps://canonical.mainnet.rpc.aztec-labs.com/
The consumer is defined at the environment level, so the key authenticates on every keyed route in that environment (e.g. mainnet v4, canonical, and v5 hosts) — one key, all routes.
Listing consumers and their owners
The clientN → real-recipient mapping lives only in the client_name annotation on each GCP secret — it is never in the committed Terraform or in the cluster. To find who owns which key, list the secrets with their annotation:
# one env
gcloud secrets list --filter="name:mainnet-rpc-consumer" \
--format="table(name.basename(), createTime, annotations.client_name)"
# every RPC consumer across all envs (mainnet, testnet, staging, testnet-prover, eth-sepolia, …)
gcloud secrets list --filter="name~rpc-consumer" \
--format="value(name.basename(), annotations.client_name)" | sort
To find a specific person's key, grep the annotation column — the owner may not have a key at all (in which case, on testnet, they are just using the anonymous tier and "bumping" them means creating a key):
gcloud secrets list --filter="name~rpc-consumer" --format="value(name.basename(), annotations.client_name)" | grep -i <name>
Cross-check against what is actually live in the cluster (the committed main.tf can lag behind live — always reconcile against origin/main, not a possibly-stale checkout):
CTX=gke_testnet-440309_us-west1-a_aztec-gke-public
kubectl --context "$CTX" -n <ns>-rpc get kongconsumer
Checking a consumer's rate limit
The declared limit is the consumer's rate_limit_minute in the env main.tf (0 = unlimited). To read the effective live limit instead of the declared one:
CTX=gke_testnet-440309_us-west1-a_aztec-gke-public
NS=testnet-rpc
# does the consumer carry a rate-limit plugin annotation? (absent ⇒ unlimited)
kubectl --context "$CTX" -n "$NS" get kongconsumer <release-prefix>-<key> -o jsonpath='{.metadata.annotations}'; echo
# the per-consumer rate-limit plugin config, if any
kubectl --context "$CTX" -n "$NS" get kongplugin <release-prefix>-<key>-rpc-rate-limit -o jsonpath='{.config}'; echo
# the anonymous (keyless) limit, per route — testnet only
kubectl --context "$CTX" -n "$NS" get kongplugin <release-prefix>-<route>-anonymous-rpc-rate-limit -o jsonpath='{.config}'; echo
# Kong pod count — the local policy multiplies the limit by this
kubectl --context "$CTX" -n "$NS" get pods | grep kong-gateway
Remember policy = "local": the real ceiling is minute × (Kong gateway pod count). With a single gateway pod, the number in the plugin is the number the client sees.
Changing a consumer's rate limit
Editing rate_limit_minute for an existing consumer, then plan/apply exactly as in Steps 3–4. The Step 3 guardrail still applies: the plan must touch only that one consumer's rate-limit plugin and its KongConsumer annotation — nothing else. Expected plan deltas by transition:
| Change | Expected plan |
|---|---|
0 → N (add a cap) | 1 to add, 1 to change — creates the rate-limit KongPlugin, annotates the consumer to use it |
N → M (both > 0) | 1 to change — just the plugin's minute |
N → 0 (remove the cap) | 1 to change, 1 to destroy — drops the plugin, removes the consumer annotation |
Notes:
- testnet caveat: a testnet consumer generated by the
consumer_secret_namesloop is uniformlyrate_limit_minute = 0. You cannot raise one testnet consumer above/below the others without pulling it out of the loop into an explicitCONSUMERSentry (see the testnet variant under Step 2). If the intent is simply "more than the 300/min anonymous tier", giving them a key already makes them unlimited — no per-consumer plugin needed. - Raising the anonymous tier (testnet, keyless users) is a different lever: set
ANONYMOUS_RATE_LIMIT_MINUTEon the testnet env module (default 300). This affects every keyless caller, not one person. - Commit message convention for a limit change is a normal
chore:/fix:describing the change (notchore: add new client), on basemain. Do not name the private owner in the PR.
Signals
- GitHub stars
- 23
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
create-rpc-api-key- Source
- github.com/aztec-labs-eng/aztec-node
github.com/aztec-labs-eng/aztec-node