Checks/OPT_ACCT_002

NATS Excessive JWT Size: Reducing Bloated Account Claims

Severity
Warning
Category
Consistency
Applies to
Account
Check ID
OPT_ACCT_002
Detection threshold
account JWT size is unusually large relative to typical deployments

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.

Why this matters

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.

Common causes

  • 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.

How to diagnose

Check JWT size

Terminal window
nsc describe account <account_name> --json | wc -c

A 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:

Terminal window
nsc describe account <account_name>

Review the sections: Imports, Exports, Signing Keys, and Revocations. Count the entries in each.

List all revocations

Terminal window
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:

Terminal window
nsc describe account <account_name> --json | jq '.nats.revocations'

Audit permission entries

Terminal window
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.

Compare across accounts

Terminal window
for acct in $(nsc list accounts -n); do
size=$(nsc describe account "$acct" --raw | wc -c)
echo "$acct: $size bytes"
done | sort -t: -k2 -rn

This ranks all accounts by JWT size, making outliers obvious.

Inspect programmatically

1
package main
2
3
import (
4
"fmt"
5
"log"
6
"os"
7
8
"github.com/nats-io/jwt/v2"
9
)
10
11
func 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
}
1
import json
2
import base64
3
import sys
4
5
def 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 padding
10
payload += "=" * (4 - len(payload) % 4)
11
return json.loads(base64.urlsafe_b64decode(payload))
12
13
with open("account.jwt") as f:
14
token = f.read()
15
16
claims = decode_jwt_payload(token)
17
nats_claims = claims.get("nats", {})
18
19
print(f"JWT size: {len(token)} bytes")
20
print(f"Imports: {len(nats_claims.get('imports', []))}")
21
print(f"Exports: {len(nats_claims.get('exports', []))}")
22
print(f"Signing keys: {len(nats_claims.get('signing_keys', []))}")
23
print(f"Revocations: {len(nats_claims.get('revocations', {}))}")

How to fix it

Immediate: prune expired revocations

Revocations that have passed their expiry time are no longer effective but remain in the JWT. Remove them:

Terminal window
# List revocations and identify expired ones
nsc describe account <account_name>
# Remove specific expired revocations
nsc 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:

Terminal window
nsc edit account <account_name> --revoke-all-users --at <timestamp>

Short-term: consolidate permissions

Replace granular subjects with wildcards. Audit the permission list and identify groups of subjects that share a common prefix:

Terminal window
# Before: 20 individual permission entries
nsc edit user <user> --allow-pub "orders.created"
nsc edit user <user> --allow-pub "orders.updated"
# ... 18 more
# After: 1 wildcard entry
nsc 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.

Short-term: clean up signing keys

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:

Terminal window
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:

Terminal window
nsc list users -a <account_name> --json | jq '.[].iss' | sort | uniq -c

Long-term: design for lean JWTs

Adopt 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:

Terminal window
# Run monthly or after each offboarding cycle
nsc 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"
done
nsc 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.

Frequently asked questions

How large is too large for an account JWT?

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.

Do large JWTs affect runtime performance?

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.

Can I compress the JWT?

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.

Do revocations expire automatically?

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.

Will removing a signing key lock out users?

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.

Proactive monitoring for NATS excessive jwt size with Synadia Insights

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.

Start a 14-day Insights trial
Cancel