Decentralized authentication
On the previous page you built the ACME
operator, pushed the ORDERS and ANALYTICS accounts to the resolver, and
connected order-svc with a credentials file. The commands worked; this
page explains what the server actually verified when that connection came
in. It then closes the one gap the build left open: order-svc is still
unrestricted, while in config mode it was limited to orders.>. By the end
it's re-issued from a scoped signing key that carries those permissions,
with an expiring credentials file and a way to revoke it.
The problem with one big user list
Why go through the operator setup at all? Suppose Acme is a year older:
the ORDERS account holds 15 services, ANALYTICS holds eight, and a
third team wants its own account. With centralized authentication (the
model from the first half of this chapter), every one of those identities
lives in the server config, and only the team holding that config can add
one.
You want each team to manage its own users without touching the server.
The server shouldn't need to know every user in advance, only a way to
tell a real user from a forged one. A signature check does that: on the
previous page you created order-svc without adding any user config to
the server.
The three identities in the trust chain
Decentralized authentication arranges identities into a chain with three links.
The operator is the root of trust. There's one per deployment —
ACME in our setup. It's the single identity the server is told to
trust.
An account is the tenant you met on the
Accounts and multitenancy page:
ORDERS and ANALYTICS in our scenario. In this model each account is
its own identity, and the operator vouches for it.
A user is the auth identity a client connects as: order-svc and
analytics-reader. Each user belongs to an account, and the account
vouches for it.
"Vouches for" has a precise meaning here: it means signs. The operator signs the account. The account signs the user. The result is a chain you can verify from any link back up to the root.
How an identity signs the next
Each identity holds a key it signs and verifies with. In NATS these are nkeys, built on Ed25519, the same elliptic-curve signature scheme used for SSH and modern code signing. An nkey comes in two forms: a public nkey others verify against, and a private seed the signer keeps. The signer signs with the seed; everyone else needs only the public nkey to check the signature. The server only ever handles public nkeys and signatures, never anyone's seed.
An nkey is easy to recognize because its first letter names its role: an
operator nkey starts with O, an account nkey with A, a user nkey with
U, and any seed with S. So OD2A... is an operator's public nkey and
SUAH... is a user's seed.
An identity isn't limited to its one built-in key pair. An account can
hold extra signing keys that also count as valid issuers for its
users — you'll use one shortly — and the operator can hold signing keys
for accounts the same way. A user signed by an account signing key carries
an issuer_account field in its JWT naming the account it belongs to.
The user JWT
A user proves who it is by presenting a JSON Web Token (JWT). A JWT is a small, signed document that states a set of claims and carries the signature proving those claims haven't been altered.
A JWT isn't the "token" from Authentication basics: that token is a password-style secret the server compares against its config, while a JWT is a signed document anyone can inspect but only the right key can produce. The credentials file from the previous page holds the user JWT in its first block; the second block is the user's seed, and the next section shows why the client needs both.
A user JWT names the user and the account that issued it. When
order-svc connects, it presents its user JWT. The server reads which
account issued it, fetches that account's own JWT from the resolver, and
checks that the account JWT was signed by the operator. Each JWT
references the next one up the chain.
What the server actually checks
The server's config replaces the user list with a single embedded
operator JWT: the long operator: eyJ... line in the server.conf you
generated. That JWT contains the operator's public key, and it can
list extra operator signing keys; any of those keys can vouch for an
account.
One more input arrives at connect time. The server sends the client a nonce, a random value generated fresh for this connection. The client signs the nonce with the user seed and sends the signature back along with the user JWT.
Given all that, the server checks:
- The nonce signature verifies against the user's public key named in the user JWT. This proves the client holds the seed right now, not just a copy of the JWT.
- The user JWT was signed by the account that issued it. The server verifies that signature against the account's identity key or one of its signing keys.
- The account JWT, fetched from the resolver, was signed by the operator. The server verifies it against the keys in the operator JWT it was configured with.
If all three hold, the user is genuine: the server admits it and applies whatever permissions and limits the JWTs carry.
The numbered checks also show why each kind of forgery fails. A copied user
JWT without the seed can't sign the nonce and fails check 1 — that's why a
stolen JWT alone is useless, and why the creds file has two sections. A
homemade user JWT fails check 2, because the attacker holds no key of
ORDERS. A homemade account JWT fails check 3, because only the
operator's seed can produce a signature the server's operator JWT vouches
for.
Why removing the user list matters
Centralized authentication checks the user against a list. Decentralized authentication checks that the user's JWT traces back to the operator, and that scales because a signature check works for a user the server has never seen — no config entry has to exist before the user connects.
This is what lets each team run its own account and create its own users.
The ORDERS team signs order-svc with keys of the ORDERS account. The
ANALYTICS team signs analytics-reader with keys of ANALYTICS.
Neither team touches the server, and the server trusts both because the
operator vouched for both accounts.
Scoped permissions with a signing key
One thing is still missing from parity with config mode. On the
Authorization page, order-svc could
publish only to orders.>; the user you created on the previous page can
publish anywhere in the account. In this model permissions travel inside
the signed JWTs, and the clean way to assign them is a scoped signing
key: an account signing key with a role name and a fixed permission set.
Every user issued by that key gets exactly those permissions. Re-issue
order-svc from a scoped key named order-writer:
- CLI
#!/bin/bash
# Re-issue order-svc from a scoped signing key instead of the ORDERS
# account's identity key. The scoped key carries a fixed permission set,
# so every user it issues gets exactly those permissions.
# Add a scoped signing key with the role name "order-writer" to ORDERS.
nats auth account keys add ORDERS order-writer \
--pub-allow 'orders.>' --sub-allow '_INBOX.>'
# Expected output (your key will differ):
# Scoped Signing Key ACQFRPTMQBCYT7QB2PRHW3XEMBZYXLOXT5V7IBYTZP3CBPV6VCW2ME5E
# Role: order-writer
# Remove the unrestricted order-svc from the previous page. --revoke
# records its key in the ORDERS account JWT, so the old creds stop
# working once the account is pushed again.
nats auth user rm order-svc ORDERS --revoke -f
# Removed user order-svc
# Re-issue order-svc signed by the scoped key. --key takes the role
# name; -f overwrites the creds file from the previous page.
nats auth user add order-svc ORDERS --key order-writer \
--defaults --credential order-svc.creds -f
# User order-svc (UASBX5L3X7MSAAAPVRKNAQFRMAFKP4VOKB6O3ZZ72QYD2DQCL2W4TP5K)
# Issuer: ACQFRPTM... (the scoped key)
# Scoped: true
# Re-mint the creds file with a 720-hour expiry. Expiry lives on the
# minted credential; the stored user is untouched.
nats auth user credential order-svc.creds order-svc ORDERS --expire 720h -f
# Wrote credential for order-svc to order-svc.creds
# Confirm the issuer and the permissions applied from the scope.
nats auth user info order-svc ORDERS
Scoped Signing Key ACQFRPTMQBCYT7QB2PRHW3XEMBZYXLOXT5V7IBYTZP3CBPV6VCW2ME5E
...
Removed user order-svc
...
User order-svc (UASBX5L3X7MSAAAPVRKNAQFRMAFKP4VOKB6O3ZZ72QYD2DQCL2W4TP5K)
Configuration:
Account: ORDERS (AC6S25M37MU5PJGKYF5QPJPJ6XDQZXJPIPTMCR5MK7ZALYQGX6MH4IRU)
Issuer: ACQFRPTMQBCYT7QB2PRHW3XEMBZYXLOXT5V7IBYTZP3CBPV6VCW2ME5E
Scoped: true
...
Permissions:
Publish:
Allow: orders.>
Subscribe:
Allow: _INBOX.>
The Issuer line is the point: order-svc is now issued by
ACQFRPTM..., the scoped key, not the ORDERS identity key, and
Scoped: true confirms its permissions come from the key's template. The
permission set shown is the role's — the same orders.> publish and
_INBOX.> subscribe it had in config mode.
The scoped key lives inside the ORDERS account JWT, so the server
doesn't know it yet. Until you push, the new credentials are rejected:
nats pub orders.created '{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200}' --creds order-svc.creds
nats: error: nats: Authorization Violation
Push the account as you did on the previous page
(nats auth account push ORDERS -s nats://127.0.0.1:4222 --creds sys.creds)
and try again:
14:24:53 Published 63 bytes to "orders.created"
Anything outside the scope now fails, and this time it's a permission error rather than an authentication one:
nats pub billing.charge 'x' --creds order-svc.creds
nats: error: nats: permissions violation: Permissions Violation for Publish to "billing.charge"
The scoped user's own JWT carries an empty permission set ("pub": {}, "sub": {}). The server applies the key's
template at connect time, so a change to the template reaches every user
signed by that key on the next account push, with no creds re-issued. The
v0.4.0 CLI can't yet edit a scope in place, though — see the Pitfalls.
The snippet also re-minted the creds file with --expire 720h, so the
user JWT inside it lapses after 30 days. Expiry belongs to the minted
credential, not the stored user: re-run nats auth user credential
whenever you need a fresh one.
Revoking a user
The snippet passed --revoke when it removed the old, unrestricted
order-svc. nats auth user rm without it
only deletes the user from your local store; credentials already handed
out keep working, because the server never consults your store. With
--revoke, the command writes an entry into the ORDERS account JWT: a
revocations map from the user's public key to a timestamp. Any user JWT
for that key issued at or before that time is rejected. Like every account
change, it takes effect on the next nats auth account push ORDERS, and
the push also disconnects clients currently connected as the revoked
user. Run the lifecycle end to end to see when the revocation takes
effect:
- CLI
#!/bin/bash
# Revoke order-svc and watch when the revocation actually bites. The
# creds keep working until the updated account JWT reaches the server.
# Baseline: the scoped creds connect fine.
nats pub orders.created '{"order_id":"ord_9x3m","customer":"acme-co","total_cents":1850}' --creds order-svc.creds
# 18:04:03 Published 63 bytes to "orders.created"
# Remove and revoke. This writes the revocation entry into your local
# copy of the ORDERS account JWT; the server hasn't seen it yet.
nats auth user rm order-svc ORDERS --revoke -f
# Removed user order-svc
# The window: the same creds still connect and publish.
nats pub orders.created '{"order_id":"ord_9x3m","customer":"acme-co","total_cents":1850}' --creds order-svc.creds
# 18:04:03 Published 63 bytes to "orders.created"
# Close the window: push the account. A client connected with the
# revoked creds is dropped as the push lands.
nats auth account push ORDERS -s nats://127.0.0.1:4222 --creds sys.creds
# ✓ Update completed on acme-1
# (in the revoked client's terminal:)
# 18:04:04 >>> Disconnected due to: EOF, will attempt reconnect
# A fresh connection with the revoked creds now fails.
nats pub orders.created 'x' --creds order-svc.creds
# nats: error: nats: Authorization Violation
# Revocation pins the old user key, not the name. Re-issuing order-svc
# creates a new key, so the new creds work after the next push.
nats auth user add order-svc ORDERS --key order-writer --defaults \
--credential order-svc.creds -f
nats auth user credential order-svc.creds order-svc ORDERS --expire 720h -f
nats auth account push ORDERS -s nats://127.0.0.1:4222 --creds sys.creds
nats pub orders.created '{"order_id":"ord_9x3m","customer":"acme-co","total_cents":1850}' --creds order-svc.creds
# 18:04:19 Published 63 bytes to "orders.created"
18:04:03 Published 63 bytes to "orders.created"
Removed user order-svc
18:04:03 Published 63 bytes to "orders.created"
...
✓ Update completed on acme-1
...
18:04:04 >>> Disconnected due to: EOF, will attempt reconnect
...
nats: error: nats: Authorization Violation
...
18:04:19 Published 63 bytes to "orders.created"
The second publish is the point: between user rm --revoke and the
push, the revocation exists only in your local store, so the revoked
creds still connect and publish. The final publish shows the other half
of the mechanic: revocation pins the old user's public key, so the
re-issued order-svc — a new key under the same name — connects fine
after the next push.
Bearer tokens
Check 1, the nonce signature, has one exception. A user can be
marked as a bearer user; its JWT then connects with no seed and no
nonce signature, so anyone holding the JWT can connect. Accounts disallow
this by default (Bearer Tokens Allowed: false in the account listing).
To use it, allow bearer tokens on the account (--bearer on
nats auth account add or account edit) and mark the user (--bearer
on nats auth user add). It's a convenience for browser and websocket
clients that have nowhere safe to keep a seed, and it reduces the
credential to a single document that must never leak. A non-bearer JWT
presented alone is still rejected with Authorization Violation. Try
both with the creds you already have:
- CLI
#!/bin/bash
# Connect with a user JWT alone, no seed. The JWT is the first block of
# a creds file; v0.4.0 has no command that prints it by itself, so cut
# it out with sed.
JWT=$(sed -n '/BEGIN NATS USER JWT/,/END NATS USER JWT/{/---/d;p;}' order-svc.creds)
# A normal user's JWT alone is rejected: with no seed, the client
# can't sign the server's nonce.
nats pub orders.created 'x' --jwt "$JWT"
# nats: error: nats: Authorization Violation
# Create a bearer user for a browser dashboard and push it.
nats auth user add orders-dashboard ORDERS --bearer --defaults \
--credential orders-dashboard.creds
# Bearer Token: true
nats auth account push ORDERS -s nats://127.0.0.1:4222 --creds sys.creds
# Marking the user isn't enough: ORDERS still has Bearer Tokens
# Allowed: false, so its JWT alone gets the same rejection.
JWT=$(sed -n '/BEGIN NATS USER JWT/,/END NATS USER JWT/{/---/d;p;}' orders-dashboard.creds)
nats pub orders.created 'x' --jwt "$JWT"
# nats: error: nats: Authorization Violation
# Allow bearer tokens on the account and push again.
nats auth account edit ORDERS --bearer
# Bearer Tokens Allowed: true
nats auth account push ORDERS -s nats://127.0.0.1:4222 --creds sys.creds
# Now the JWT connects on its own: no seed, no nonce signature.
nats pub orders.created '{"order_id":"ord_2f7q","customer":"acme-co","total_cents":990}' --jwt "$JWT"
# 18:05:09 Published 62 bytes to "orders.created"
nats: error: nats: Authorization Violation
...
Bearer Token: true
...
nats: error: nats: Authorization Violation
...
Bearer Tokens Allowed: true
...
18:05:09 Published 62 bytes to "orders.created"
Both rejections print the same Authorization Violation: the first
because a non-bearer JWT arrives with no nonce signature, the second
because the account still disallows bearer tokens. Only after the
account-level allow is pushed does the JWT connect on its own.
Pitfalls
Four things commonly catch teams new to decentralized authentication.
Losing the operator seed. The operator's seed is the only thing that
can sign accounts. Lose it and you can't add or re-sign an account; the
tooling has nothing to sign with. Back the operator up before you build
anything on top of it: nats auth operator backup ACME acme-operator.backup
writes a portable backup file — a JSON document holding the operator's
keys and JWTs, so an unencrypted backup contains the operator seed in
cleartext — and --key encrypts it with a curve nkey (pass a file
containing the key). Restore it with
nats auth operator restore ACME acme-operator.backup. Store the file
offline.
Pasting a seed where a public key belongs. Server config and JWT
fields only ever take public nkeys (O..., A..., U...). If you paste
a seed (S...) into a config, a chat message, or a log, you've handed out
the one secret that must stay private, and the only fix is to rotate the
key. Treat every S-prefixed string like a password.
Signing users with the account's identity key. This is what plain
nats auth user add does, and it works — but permissions then live on
each user, and whoever holds the account's seed can issue a user with any
permissions. A scoped key pins the permissions up front, so a leaked
signing key can only issue users with the scope you already chose (see
ADR-14).
Two operational limits to know: CLI v0.4.0 has no command to edit a scope
in place, and nats auth account keys rm takes --key <public-key>, not
the role name. Removing a key and re-adding the role creates a new key,
and every user signed by the old one is locked out at the next push with
nats: Authorization Violation. Treat key removal as mass revocation, not
as an edit.
Not planning for credential expiry. A user JWT minted by
nats auth user add never expires, so a leaked creds file is valid until
you revoke it. The only expiry control in v0.4.0 is on the credentials
file: nats auth user credential order-svc.creds order-svc ORDERS --expire 720h -f
mints a fresh user JWT that lapses after 720 hours; there's no expiry flag
on user add or user edit. Pair a short expiry with a renewal step,
because a lapsed client is rejected with plain Authorization Violation.
The server's default log records only an authentication error; the
underlying reason, claim is expired, appears in its debug log.
Where you are
The ACME setup from the previous page is unchanged; this page layered
the model onto it and tightened order-svc:
- The server trusts one operator JWT and verifies three signatures per connection: the nonce, the user JWT, and the account JWT.
order-svcis re-issued from theorder-writerscoped key: publish limited toorders.>, subscribe to_INBOX.>, matching its config-mode permissions.- Its creds file expires in 720 hours, and the old unrestricted user key is revoked.
analytics-readeris still a plain user ofANALYTICS, not signed by a scoped key.
What's next
Both authentication styles so far have the server decide from something it holds: a user list in its config, or a trust chain in JWTs. Sometimes the data that decides who may connect lives in a system NATS can't read, and the next page hands the decision to a service you run.
Continue to Auth callout.
See also
- Reference → operator — the config field that embeds the operator JWT.
- Core Concepts → Security — the five-minute overview of the same trust model.
- Operator mode — the hands-on build this page explains.
- ADR-14 — issuing user JWTs under scoped signing keys, and why scoped keys limit the blast radius of a leaked key.