Authorization
By the end of Authentication
basics, order-svc and
analytics-reader can prove who they are. The server checks their
passwords and admits the connections.
That's authentication: the server knows who each connection is, but
nothing limits what it can do. Right now order-svc can publish to any
subject on the server and subscribe to any subject.
This page adds those limits. Authorization is what a user may do, and in NATS that's always expressed as a set of subjects.
Permissions are about subjects
A permission is a grant to publish to, or subscribe to, a set of subjects. There's no separate notion of an admin role or a resource type. Every right a user has is a subject it may publish to or a subject it may subscribe to.
This follows from how NATS works. Everything a client does travels over a subject: a publish names a subject, a subscribe names a subject, and a request/reply is a publish plus a subscribe on a reply subject. Because of that, controlling the subjects controls everything the user can reach.
So a user's authorization is two lists: the subjects it may publish to, and the subjects it may subscribe to. The two are independent. A user can be allowed to publish to a subject it can't subscribe to, or the reverse.
Permissions use the same subject wildcards you already know from
Core Concepts → Subjects. * matches one token;
> matches one or more trailing tokens. orders.> covers
orders.created, orders.shipped, and orders.cancelled in one
grant.
Restricting order-svc
order-svc exists to publish order events: orders.created,
orders.shipped, and orders.cancelled. It also makes the occasional
request — a lookup before confirming an order — and a request needs a
reply, which arrives on a temporary inbox subject under _INBOX..
That's its whole footprint: publish under orders., subscribe to its
own inboxes.
In the config from Authentication
basics, order-svc was a bare
user with a password. Now it gets a permissions block:
authorization {
users: [
{
user: order-svc
password: s3cr3t
permissions: {
publish: {
allow: ["orders.>"]
}
subscribe: {
allow: ["_INBOX.>"]
}
}
}
{user: analytics-reader, password: an4lytics}
]
}
The publish permission has an allow list with one entry,
orders.>. order-svc may publish to any subject under orders..
The subscribe permission allows _INBOX.>, the prefix where request
replies arrive. Allowing the prefix lets
order-svc receive replies and nothing else. (The inbox prefix is
configurable, but _INBOX. is the default.)
analytics-reader has no permissions block, so it stays
unrestricted for now. The next section explains why that's the rule.
An allow-list closes everything else
The moment you write an allow list, every subject not on it is
denied.
So publish: { allow: ["orders.>"] } grants orders.> and at the same
time denies everything else, from billing.charge to the JetStream API
under $JS.API.>.
This is why a permission with no allow and no deny means
unrestricted. That's analytics-reader right now: a user with no
permissions block can do anything on the server.
One warning: an empty allow list is not a lock-down. publish: []
parses as no list at all, so the user can publish anywhere. To block
all publishes, write publish: { deny: [">"] }.
There's also a fallback for users you haven't scoped yet. A
default_permissions block inside authorization {} applies to every
user that has no permissions block of its own. A user with its own
block ignores the defaults entirely — the two are never merged. The
fields are in Reference →
default_permissions.
Authorization is opt-in: you opt in by writing an allow list.
Deny beats allow
Sometimes you want "all of orders.>, except one subject." You could
craft a precise allow-list that enumerates everything but the
exception, but that's brittle. Instead you allow the broad pattern and
deny the exception:
publish: {
allow: ["orders.>"]
deny: ["orders.secret"]
}
When a subject matches both lists, deny wins. The server checks
allow first, then checks deny, and a match in deny overrides the
allow. With this block order-svc could publish orders.created and
orders.shipped but never orders.secret, even though the wildcard
covers it.
You rarely need both lists, but when you do, deny is what the server applies last.
Observing a denial
Restart the server with the config above and connect as order-svc. A
publish to orders.created is on the allow-list and goes through. A
publish to billing.charge isn't on the allow-list, so the server
rejects it.
- CLI
#!/bin/bash
# order-svc is restricted to publishing orders.> and subscribing _INBOX.>.
# Assumed server config (top-level authorization block):
# authorization {
# users: [
# {
# user: order-svc
# password: s3cr3t
# permissions: {
# publish: { allow: ["orders.>"] }
# subscribe: { allow: ["_INBOX.>"] }
# }
# }
# {user: analytics-reader, password: an4lytics}
# ]
# }
# This shows an allowed publish succeeding and a denied publish being rejected.
# Connect as order-svc.
export NATS_USER=order-svc
export NATS_PASSWORD=s3cr3t
# Allowed: orders.created is covered by the publish allow-list "orders.>".
# Expected output:
# Published 91 bytes to "orders.created"
nats pub orders.created '{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'
# Denied: billing.charge is not on the allow-list, so the server rejects it
# and drops the message. The CLI exits 1 with:
# nats: error: nats: permissions violation: Permissions Violation for Publish to "billing.charge"
nats pub billing.charge '{"order_id":"ord_8w2k","amount_cents":4200}'
14:19:45 Published 91 bytes to "orders.created"
nats: error: nats: permissions violation: Permissions Violation for Publish to "billing.charge"
The first publish reports its 91 bytes delivered and exits 0. The
second exits 1 with the error above. On the wire, the server's raw
protocol message is -ERR 'Permissions Violation for Publish to "billing.charge"'; the CLI wraps it in its own prefix.
The rejection is reported, not silent. The server sends the client an error and, for a publish, drops the message. The error is asynchronous and the connection stays open, so the client can keep working. Clients surface it differently: some log it, some raise it on the next operation, and some expose it through an async error handler.
One shape of denial does look silent: a denied request. A request is a
publish, so when the publish is denied, no responder ever sees it and
the requester just times out. Every JetStream API call is a request
under the hood, so a locked-down user running nats stream info fails
with context deadline exceeded rather than a permission error. When a
request times out for no clear reason, check the server log — every
violation is recorded there:
[ERR] 127.0.0.1:57456 - cid:6 - "v1.51.0:go:NATS CLI Version v0.4.0" - "$G/user:order-svc" - Publish Violation - Subject "billing.charge"
The log line names the user — still in the global account $G — and
the subject, which is usually all you need to find the missing grant.
The same model under decentralized auth
Later in this chapter the same allow and deny lists move out of the
config file and into signed JWTs — that's Decentralized
authentication. There you edit the
lists with the nats auth CLI instead of a text editor:
nats auth user edit order-svc ORDERS --pub-allow "orders.>"
One caveat to remember when you get there: each flag replaces that entire list, so always pass the complete set of subjects. And with scoped signing keys — the recommended setup — the user's JWT carries empty publish and subscribe lists, and the permissions live in the account's signing-key scope instead. The server enforces the same two rules either way.
What we're leaving out
Three related grants belong to authorization but aren't needed to scope
order-svc, so we name them and move on.
- Response permissions (
allow_responses) — let a service reply to requests without a broad publish allow; the server tracks each reply subject it handed out and permits exactly that one reply. Used once a request/reply service exists. - Queue-group permissions — scope a subscription to a named queue
group. A subscribe entry of the form
"orders.created billing-workers"permits subscribing toorders.createdonly as a member of thebilling-workersqueue group; a plain subscribe to the same subject stays denied. The matching rules are in Reference → permissions. - Import and export permissions — govern subjects shared across account boundaries. Those are a property of an account, not a user, and the Cross-account page covers them once accounts exist.
The full set of permission fields is documented in Reference →
permissions. We use
only publish, subscribe, allow, and deny here.
Pitfalls
Four failures cover most of the ways the two rules above go wrong.
A subscribe deny silently breaks request-reply. A request needs a
reply, and the reply lands on a temporary inbox subject the client
subscribes to before it publishes. A user with
subscribe: { deny: [">"] } can never create that subscription, so the
reply has nowhere to go. Adding allow: ["_INBOX.>"] next to the deny
doesn't help — deny beats allow, and the violation persists. The fix is
to replace the deny with the allow, which is exactly what order-svc's
config on this page does.
- CLI
#!/bin/bash
# Broken state: order-svc's subscribe section denies everything:
# permissions: {
# publish: { allow: ["orders.>"] }
# subscribe: { deny: [">"] }
# }
# A request subscribes to a temporary _INBOX.> subject before it publishes,
# and the deny blocks that subscription, so no reply can ever arrive.
# Connect as order-svc.
export NATS_USER=order-svc
export NATS_PASSWORD=s3cr3t
# The CLI is silent about the denial: it prints only
# Sending request on "orders.lookup"
# then waits out the timeout and exits 0 — no error. The violation appears
# in the SERVER log:
# [ERR] ... "$G/user:order-svc" - Subscription Violation - Subject "_INBOX.<random>", SID 1
# (Client libraries raise a timeout error on the request instead.)
nats req orders.lookup '{"order_id":"ord_8w2k"}' --timeout 2s
# Fix: REPLACE the subscribe section in the server config — adding an allow
# next to the deny does nothing, because deny beats allow:
# subscribe: { allow: ["_INBOX.>"] }
# Reload the server and run the same request again, with a responder
# listening on orders.lookup. Expected output:
# Sending request on "orders.lookup"
# Received with rtt 652µs
# {"order_id":"ord_8w2k","status":"shipped"}
nats req orders.lookup '{"order_id":"ord_8w2k"}' --timeout 2s
14:20:30 Sending request on "orders.lookup"
In the broken state that first line is all the CLI prints: it waits out
its timeout and exits 0, with no error at all. The denial shows up only
in the server log, as Subscription Violation - Subject "_INBOX.<random>", SID 1. Client libraries surface it more clearly:
they raise a timeout error on the request. After the subscribe section is replaced with
allow: ["_INBOX.>"] and a responder is listening, the same request
completes:
14:20:11 Sending request on "orders.lookup"
14:20:11 Received with rtt 652µs
{"order_id":"ord_8w2k","status":"shipped"}
The same problem exists in the other direction: a service that answers
requests must be able to publish to the reply subject it was handed.
allow_responses, named above, is the clean way to grant that.
An allow-list that forgets a needed subject closes it off too.
Because an allow list denies everything not on it, a missing entry is
a silent block, not a warning. The day order-svc needs to publish
orders.refunded, a publish: { allow: ["orders.>"] } already covers
it, but a narrower allow: ["orders.created", "orders.shipped"] would
reject it with a Permissions Violation and no other signal. Prefer
the wildcard that matches the user's real subject space over an
enumerated list you must remember to grow.
An over-broad > grants every subject on the server. Granting
publish: { allow: [">"] } or subscribe: { allow: [">"] } to save
typing gives the user every subject, including, when JetStream is on,
the $JS.API.> control plane. That's the same "no permissions means
anything" gap from earlier, written out explicitly. Scope each user to
the subject prefix it actually uses: orders.> for order-svc, never
>.
A deny can be invisible under a wildcard subscription. A literal
subscribe to a denied subject is rejected loudly: under
subscribe: { allow: ["orders.>"], deny: ["orders.audit.>"] }, a
nats sub orders.audit.entry fails with Permissions Violation for Subscription to "orders.audit.entry". But a wildcard subscription that
merely overlaps the deny is accepted, and the server filters the denied
subjects out at delivery time. The same user subscribed to orders.>
receives orders.created and orders.shipped but never
orders.audit.entry — no error, no gap marker. If a subscriber seems
to miss messages, check its deny lists before suspecting the publisher.
- CLI
#!/bin/bash
# order-svc's subscribe section allows the wildcard but denies the audit branch:
# permissions: {
# publish: { allow: ["orders.>"] }
# subscribe: { allow: ["orders.>"], deny: ["orders.audit.>"] }
# }
# A literal subscribe to a denied subject fails loudly; a wildcard subscribe
# that overlaps the deny is accepted and silently filtered.
# Connect as order-svc.
export NATS_USER=order-svc
export NATS_PASSWORD=s3cr3t
# Loud: a literal subscribe to the denied subject is rejected. The CLI
# exits 1 with:
# Subscribing on orders.audit.entry
# nats: error: nats: permissions violation: Permissions Violation for Subscription to "orders.audit.entry"
nats sub orders.audit.entry
# Silent: the wildcard subscribe is ACCEPTED — no error, and nothing in the
# server log either. Leave it running (--wait exits after 4s without
# messages) and publish to both subjects from another terminal:
# nats pub orders.created '{"order_id":"ord_8w2k","status":"created"}'
# nats pub orders.audit.entry '{"order_id":"ord_8w2k","actor":"order-svc"}'
# Only orders.created is delivered; the server drops orders.audit.entry
# at delivery time. Expected output:
# Subscribing on orders.>
# [#1] Received on "orders.created"
# {"order_id":"ord_8w2k","status":"created"}
nats sub "orders.>" --wait 4s
18:02:37 Subscribing on orders.audit.entry
nats: error: nats: permissions violation: Permissions Violation for Subscription to "orders.audit.entry"
The literal subscribe exits 1 with the violation above. The wildcard subscribe is accepted, and with both subjects published while it runs, only one message arrives:
18:02:45 Subscribing on orders.>
[#1] Received on "orders.created"
{"order_id":"ord_8w2k","status":"created"}
The publish to orders.audit.entry reports success, but the server
drops it at delivery: no client error, and — unlike the literal
subscribe — no violation line in the server log either.
Where you are
order-svc is now scoped to exactly what it does:
- It may publish to
orders.>and subscribe to_INBOX.>, nothing else. - A publish to any other subject returns a
Permissions Violationand is dropped. analytics-readeris still unrestricted, and both users still share one subject space: the global account$G.
You also have the two rules that govern every permission you'll ever
write: an allow list closes everything else off, and deny beats
allow on overlap. The same model applies whether the lists live in
config or in a JWT.
What's next
order-svc is scoped, but both users still operate in one shared
subject space — anything one can reach, an unrestricted user can reach
too. The next page gives each service its own account, with a subject
space nobody else can see into.
Continue to Accounts & multitenancy.
See also
- Reference → permissions — every permission field and its defaults.
- Reference → default_permissions — fallback permissions for users without their own block.
- Core Concepts → Subjects — the wildcard rules that permissions are built on.
- Accounts & multitenancy — giving each user its own subject space.