NATS operator-mode deployments use JWTs (JSON Web Tokens) to encode account and user claims — permissions, imports, exports, signing keys, and revocations. When an account JWT grows unusually large, it indicates excessive permissions entries, many accumulated revocations, a large number of signing keys, or sprawling import/export configurations. This check flags accounts with JWT sizes that exceed normal thresholds.
Account JWTs are not just stored — they’re actively resolved on every client connection. When a client connects, the server fetches or looks up the account JWT, decodes it, and applies its claims. A bloated JWT increases the time and memory required for this resolution. In high-connection-rate environments — rolling deployments, autoscaling events, reconnection storms after a network partition — every millisecond of connection overhead multiplies across thousands of simultaneous connects.
The JWT is also propagated across the cluster. In operator-mode deployments using the built-in account resolver, the JWT is stored in every server’s resolver cache and replicated via system account messages. A 50 KB JWT replicated to 20 servers across 3 clusters means 3 MB of claim data for a single account. Multiply that by dozens of accounts with similarly bloated claims, and the system account traffic and memory overhead become meaningful.
Large JWTs also create operational friction. nsc operations slow down because every edit re-encodes the entire JWT. Code reviews of JWT changes become difficult when the claim contains hundreds of permission entries. And debugging authorization failures is harder when the permission set is sprawling — it’s not clear whether a particular subject is allowed by an explicit rule, a wildcard, or an import.
The most insidious aspect is that JWT size tends to grow monotonically. Permissions are added but rarely removed. Revocations accumulate as users are offboarded. Signing keys are rotated but old ones aren’t cleaned up. Without periodic maintenance, the JWT becomes a append-only log of every authorization decision ever made for the account.
Granular per-subject permissions instead of wildcards. Permissions like allow publish orders.created, orders.updated, orders.deleted, orders.cancelled should be allow publish orders.*. When developers add subjects one at a time instead of designing a wildcard-friendly hierarchy, the permission list grows linearly with the subject namespace.
Accumulated revocations. Every user or activation token revocation adds an entry to the account JWT. In organizations with regular employee turnover or credential rotation, revocations pile up. Revocations with explicit expiry eventually become inert but remain in the JWT unless manually pruned.
Many signing keys. Each signing key rotation adds a new key to the account. If old signing keys aren’t removed after all users have been re-issued credentials against the new key, the signing key list grows with each rotation cycle.
Sprawling imports and exports. Service and stream imports/exports each add entries to the JWT. A service mesh pattern where every account imports from every other account can create dozens of import entries per account.
Duplicate or overlapping permission entries. The same permission expressed in slightly different ways — orders.> and orders.* and orders.us.> — inflates the JWT without adding meaningful authorization scope.
Copy-paste account configuration. New accounts created by duplicating an existing account’s permissions inherit the full permission set, including entries irrelevant to the new account’s workload.
nsc describe account <account_name> --json | wc -cA typical account JWT is 2–5 KB. JWTs above 10 KB deserve attention; above 25 KB is almost certainly bloated.
For a human-readable view of what’s in the JWT:
nsc describe account <account_name>Review the sections: Imports, Exports, Signing Keys, and Revocations. Count the entries in each.
nsc describe account <account_name> --json | jq '.nats.revocations | length'A high revocation count (>50) is a strong indicator of JWT bloat. Check for expired revocations that can be pruned:
nsc describe account <account_name> --json | jq '.nats.revocations'nsc describe account <account_name> --json | jq '{ imports: (.nats.imports | length), exports: (.nats.exports | length), signing_keys: (.nats.signing_keys | length), revocations: (.nats.revocations | length)}'This gives a quick breakdown of which category contributes most to the JWT size.
for acct in $(nsc list accounts -n); do size=$(nsc describe account "$acct" --raw | wc -c) echo "$acct: $size bytes"done | sort -t: -k2 -rnThis ranks all accounts by JWT size, making outliers obvious.
1package main2
3import (4 "fmt"5 "log"6 "os"7
8 "github.com/nats-io/jwt/v2"9)10
11func main() {12 token, err := os.ReadFile("account.jwt")13 if err != nil {14 log.Fatal(err)15 }16
17 claims, err := jwt.DecodeAccountClaims(string(token))18 if err != nil {19 log.Fatal(err)20 }21
22 fmt.Printf("JWT size: %d bytes\n", len(token))23 fmt.Printf("Imports: %d\n", len(claims.Imports))24 fmt.Printf("Exports: %d\n", len(claims.Exports))25 fmt.Printf("Signing keys: %d\n", len(claims.SigningKeys))26 fmt.Printf("Revocations: %d\n", len(claims.Revocations))27}1import json2import base643import sys4
5def decode_jwt_payload(jwt_str: str) -> dict:6 """Decode the payload section of a NATS JWT."""7 parts = jwt_str.strip().split(".")8 payload = parts[1]9 # Add padding10 payload += "=" * (4 - len(payload) % 4)11 return json.loads(base64.urlsafe_b64decode(payload))12
13with open("account.jwt") as f:14 token = f.read()15
16claims = decode_jwt_payload(token)17nats_claims = claims.get("nats", {})18
19print(f"JWT size: {len(token)} bytes")20print(f"Imports: {len(nats_claims.get('imports', []))}")21print(f"Exports: {len(nats_claims.get('exports', []))}")22print(f"Signing keys: {len(nats_claims.get('signing_keys', []))}")23print(f"Revocations: {len(nats_claims.get('revocations', {}))}")Revocations that have passed their expiry time are no longer effective but remain in the JWT. Remove them:
# List revocations and identify expired onesnsc describe account <account_name>
# Remove specific expired revocationsnsc edit account <account_name> --rm-revocation <public_key>nsc push -a <account_name>For accounts with many revocations, consider replacing individual user revocations with a blanket time-based revocation. All users issued before a certain time are revoked with a single entry:
nsc edit account <account_name> --revoke-all-users --at <timestamp>Replace granular subjects with wildcards. Audit the permission list and identify groups of subjects that share a common prefix:
# Before: 20 individual permission entriesnsc edit user <user> --allow-pub "orders.created"nsc edit user <user> --allow-pub "orders.updated"# ... 18 more
# After: 1 wildcard entrynsc edit user <user> --allow-pub "orders.*"Remove duplicate and overlapping permissions. If the permission list includes both orders.> and orders.us.>, the latter is redundant. Remove it.
Consolidate imports and exports. If an account imports the same service from multiple accounts, consider using a single intermediary account as a hub. Reduce the number of direct import/export relationships.
Remove retired signing keys. After a key rotation, once all users have been re-issued credentials against the new signing key, remove the old one:
nsc edit account <account_name> --rm-sk <old_signing_key>nsc push -a <account_name>Verify no active users reference the old key before removing it:
nsc list users -a <account_name> --json | jq '.[].iss' | sort | uniq -cAdopt a wildcard-first permission model. Design subject namespaces with authorization in mind. Subjects like {team}.{service}.{action} allow broad permissions like myteam.> instead of enumerating every subject.
Automate revocation cleanup. Build a periodic process that prunes expired revocations from account JWTs. This prevents monotonic growth:
# Run monthly or after each offboarding cyclensc describe account <account_name> --json | \ jq -r '.nats.revocations | to_entries[] | select(.value < now) | .key' | \ while read key; do nsc edit account <account_name> --rm-revocation "$key" donensc push -a <account_name>Set a JWT size budget per account. Establish a team convention that account JWTs should stay under 10 KB. Include JWT size in CI/CD checks for nsc changes.
Monitor JWT sizes across the deployment. Synadia Insights flags accounts with unusually large JWTs automatically. For custom monitoring, export JWT sizes as a metric and alert on growth.
Most well-maintained account JWTs are 2–5 KB. JWTs between 5–10 KB are common in larger deployments but should be reviewed periodically. Anything above 15–20 KB almost certainly contains redundant permissions, stale revocations, or retired signing keys that should be cleaned up.
Yes. Account JWTs are decoded during connection establishment. A larger JWT takes more time to parse and more memory to store in the server’s resolver cache. In high-churn environments with thousands of connections per minute, the cumulative overhead is measurable. The impact is proportional to both JWT size and connection rate.
No. NATS JWTs follow the standard JWT format (base64-encoded JSON with a signature). They are not compressed at the protocol level. The only way to reduce size is to reduce the claim contents — fewer permissions, fewer revocations, fewer signing keys.
Revocations can have an expiry time, after which they stop being enforced. However, expired revocations remain in the JWT until explicitly removed. They contribute to JWT size without providing any security benefit. Periodic cleanup of expired revocations is essential.
Yes, if any active users were issued credentials signed by that key. Before removing a signing key, verify that all active users reference a different issuer. Re-issue credentials for any users still signed by the key being removed.
With 100+ always-on audit Checks from the NATS experts, Insights helps you find and fix problems before they become costly incidents.
No alert rules to write. No dashboards to maintain.
News and content from across the community