Securing NATS: Authentication and Authorization

A comprehensive guide to securing NATS deployments with authentication mechanisms, fine-grained authorization, TLS encryption, and account-based multi-tenancy, with practical TypeScript client configuration examples.

technical10 min readBy Klivvr Engineering
Share:

A messaging system carries some of the most sensitive data in any architecture: inter-service commands, financial events, user data, and operational telemetry. Securing this data in transit and controlling who can publish or subscribe to which subjects is not optional. NATS provides a layered security model that starts with transport encryption, adds identity verification through multiple authentication mechanisms, and enforces fine-grained access control through authorization policies and account isolation. This article covers each layer and shows how to configure the node-nats TypeScript client for secure production deployments.

Transport Security with TLS

The first layer of security is encrypting all traffic between clients and servers, and between servers in a cluster. NATS supports TLS natively, and in production it should always be enabled.

On the server side, TLS configuration specifies the certificate, private key, and optionally a CA certificate for mutual TLS:

# nats-server.conf
tls {
  cert_file: "/etc/nats/certs/server-cert.pem"
  key_file: "/etc/nats/certs/server-key.pem"
  ca_file: "/etc/nats/certs/ca-cert.pem"
  verify: true  # Require client certificates (mutual TLS)
  timeout: 5
}

On the client side, the node-nats library accepts TLS options through the connection configuration:

import { connect } from "nats";
import { readFileSync } from "fs";
 
async function connectWithTLS() {
  const nc = await connect({
    servers: "tls://nats.production.internal:4222",
    tls: {
      caFile: "/etc/nats/certs/ca-cert.pem",
      certFile: "/etc/nats/certs/client-cert.pem",
      keyFile: "/etc/nats/certs/client-key.pem",
    },
    name: "order-service-prod",
  });
 
  console.log(`Connected with TLS to ${nc.getServer()}`);
  return nc;
}

When verify: true is set on the server, every client must present a valid certificate signed by the configured CA. This is mutual TLS (mTLS), and it provides both encryption and identity verification in a single mechanism. The server rejects connections from clients without valid certificates before any application-level authentication occurs.

For environments where certificate management is complex, you can use TLS for encryption without client certificate verification by setting verify: false on the server. In this case, you would rely on one of the other authentication mechanisms described below for identity verification.

Token-Based Authentication

The simplest authentication mechanism is a shared token. The server requires a token, and each client must present it during connection:

# nats-server.conf
authorization {
  token: "s3cr3t_t0k3n_h3r3"
}
import { connect } from "nats";
 
async function connectWithToken() {
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    token: process.env.NATS_TOKEN,
  });
 
  return nc;
}

Token authentication is appropriate for development and simple deployments. In production, it has limitations: all clients share the same identity, there is no way to assign different permissions to different clients, and rotating the token requires updating every client simultaneously.

User/Password Authentication

A step up from tokens, user/password authentication assigns unique credentials to each client or service:

# nats-server.conf
authorization {
  users = [
    { user: "order-service", password: "$2a$11$..." }
    { user: "payment-service", password: "$2a$11$..." }
    { user: "monitoring", password: "$2a$11$...",
      permissions: {
        subscribe: { allow: ">" }
        publish: { deny: ">" }
      }
    }
  ]
}
import { connect } from "nats";
 
async function connectWithCredentials() {
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    user: process.env.NATS_USER,
    pass: process.env.NATS_PASSWORD,
  });
 
  return nc;
}

The monitoring user in this example can subscribe to any subject but cannot publish. This demonstrates the inline permission system that NATS provides with user-based authentication. Each user can have separate publish and subscribe permissions, including allow and deny lists.

NKeys: Public Key Authentication

NKeys provide authentication through Ed25519 public key cryptography. Each client has a private key (the seed) that never leaves the client. The server only knows the public key. This eliminates the risk of password exposure and makes credential rotation straightforward because the server does not store secrets.

# nats-server.conf
authorization {
  users = [
    { nkey: "UABJHNYTT2XGSAJBWZBA3GNVPCS5SJQWIRPO4U2XCUNBEBAOABIHUGAS" }
  ]
}
import { connect, credsAuthenticator, nkeyAuthenticator } from "nats";
import { readFileSync } from "fs";
 
async function connectWithNKey() {
  const seed = new TextEncoder().encode(process.env.NATS_NKEY_SEED!);
 
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    authenticator: nkeyAuthenticator(seed),
  });
 
  return nc;
}

The connection handshake works as follows: the server sends a challenge (a random nonce), the client signs it with its private key, and the server verifies the signature against the public key. The private key is never transmitted. If the server's user database is compromised, the attacker only has public keys, which cannot be used to authenticate.

NKeys are the recommended authentication mechanism for production deployments where the full JWT-based decentralized model (described next) is not needed.

Decentralized Authentication with JWTs

NATS's most sophisticated authentication mechanism uses JWTs (JSON Web Tokens) signed by trusted operators. This decentralized model allows account administrators to issue credentials without modifying the server configuration. It is the foundation of NATS's multi-tenancy system.

The hierarchy is: Operator > Account > User. The operator controls the NATS infrastructure. Accounts provide isolation between tenants. Users belong to accounts and have specific permissions.

# Generate operator, account, and user credentials
nsc add operator --name MyOperator
nsc add account --name OrderService
nsc add user --name order-worker --account OrderService
 
# Export credentials file
nsc generate creds -a OrderService -n order-worker > order-worker.creds
import { connect, credsAuthenticator } from "nats";
import { readFileSync } from "fs";
 
async function connectWithJWT() {
  const creds = readFileSync("/etc/nats/creds/order-worker.creds");
 
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    authenticator: credsAuthenticator(creds),
  });
 
  return nc;
}

The .creds file contains both the JWT (which identifies the user and their permissions) and the NKey seed (which proves the client's identity). The server validates the JWT's signature chain: the user JWT is signed by the account key, and the account JWT is signed by the operator key. The server trusts the operator key, and this chain of trust flows down to individual users.

The power of this model is that account administrators can create, modify, and revoke user credentials without any server configuration changes. The server resolves credentials dynamically by checking the JWT against its trusted operator keys.

Fine-Grained Authorization

Regardless of the authentication mechanism, NATS supports detailed authorization policies that control what each identity can do. Permissions are defined per user (or per account in the JWT model) and specify which subjects can be published to and subscribed to:

# nats-server.conf -- inline permissions
authorization {
  users = [
    {
      user: "order-service"
      password: "$2a$11$..."
      permissions: {
        publish: {
          allow: [
            "events.orders.>",
            "services.orders.>",
            "$JS.API.>"  # JetStream API access
          ]
          deny: [
            "events.payments.>",
            "admin.>"
          ]
        }
        subscribe: {
          allow: [
            "services.orders.>",
            "services.users.>",
            "events.payments.completed",
            "_INBOX.>"
          ]
        }
      }
    }
  ]
}

This configuration allows the order service to publish its own events and service endpoints, access JetStream APIs, and subscribe to its own service subjects plus payment completion events. It is explicitly denied from publishing to payment events or admin subjects.

In the JWT model, permissions are embedded in the user JWT:

# Grant specific permissions when creating a user
nsc add user --name order-worker \
  --account OrderService \
  --allow-pub "events.orders.>,services.orders.>,$JS.API.>" \
  --allow-sub "services.orders.>,services.users.>,events.payments.completed,_INBOX.>" \
  --deny-pub "events.payments.>,admin.>"

The wildcard _INBOX.> in the subscribe permissions is important: it allows the client to receive request/reply responses. Without it, the nc.request() call would fail because the client cannot subscribe to its own inbox subjects.

Account-Based Multi-Tenancy

NATS accounts provide hard isolation between groups of users. Each account has its own subject namespace: a publisher in Account A and a subscriber in Account B cannot communicate, even if they use the same subject name. This is enforced at the server level, not through convention.

Accounts can selectively share subjects through exports and imports:

# OrderService account exports its order events
nsc add export --account OrderService \
  --name "OrderEvents" \
  --subject "events.orders.>" \
  --service false  # Stream export (not request/reply)
 
# NotificationService account imports order events
nsc add import --account NotificationService \
  --name "OrderEvents" \
  --src-account OrderService \
  --remote-subject "events.orders.>" \
  --local-subject "imported.orders.>"

In this setup, the NotificationService sees order events on imported.orders.> in its own namespace. The mapping is transparent to the application code:

// notification-service.ts
// This service is authenticated under the NotificationService account
async function startNotificationService() {
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    authenticator: credsAuthenticator(creds),
  });
 
  // Subscribe to the locally-mapped subject
  const sub = nc.subscribe("imported.orders.>");
 
  for await (const msg of sub) {
    const event = jc.decode(msg.data);
    console.log(`Order event received: ${msg.subject}`);
    await sendNotification(event);
  }
}

Account isolation is particularly valuable in multi-tenant SaaS platforms and in organizations where different teams should not have unrestricted access to each other's messaging traffic. Combined with JetStream, each account can have its own streams and consumers, with storage limits enforced per account.

Credential Rotation and Revocation

Security is not a one-time configuration. Credentials must be rotatable, and compromised credentials must be revocable. Each authentication mechanism handles this differently.

For token and password authentication, rotation requires updating both the server configuration and all affected clients. This is operationally expensive and typically involves a maintenance window.

For NKeys, rotation involves generating a new key pair, adding the new public key to the server, and deploying the new seed to the client. You can have both old and new keys valid simultaneously during the transition.

For JWTs, rotation and revocation are significantly easier. New credentials can be issued without server changes. Revocation is handled through revocation lists:

# Revoke a compromised user immediately
nsc revocations add-user --account OrderService --name compromised-worker
 
# The server checks revocation lists on every authentication attempt

The node-nats client handles credential updates through reconnection. When you deploy new credentials and restart the service, it authenticates with the new credentials on its next connection:

// In Kubernetes, mount credentials as a secret and restart the pod
async function connectWithRotatableCredentials() {
  const credsPath = process.env.NATS_CREDS_PATH || "/etc/nats/creds/service.creds";
  const creds = readFileSync(credsPath);
 
  const nc = await connect({
    servers: "nats://nats.internal:4222",
    authenticator: credsAuthenticator(creds),
    maxReconnectAttempts: -1,
  });
 
  return nc;
}

Practical Tips for NATS Security

Start with TLS from day one, even in development. Retrofitting TLS into an existing deployment is far more disruptive than starting with it. Use a tool like mkcert to generate locally-trusted certificates for development environments.

Use the principle of least privilege when defining permissions. Each service should only be able to publish to its own event subjects and subscribe to the subjects it explicitly needs. Overly broad permissions (allowing > for everything) defeat the purpose of authorization.

Store credentials in a secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets) rather than in environment variables or configuration files. The node-nats client accepts credentials as byte arrays, so you can load them from any source.

Audit your permissions periodically. As services evolve, their actual subject usage changes, and permissions that were once appropriate may be either too broad or too narrow. NATS server logs record authorization violations, which can guide permission refinements.

Test authorization failures explicitly. Write tests that verify a service cannot publish to subjects it should not have access to, and that unauthorized subscribe attempts are rejected. These tests catch permission regressions that configuration reviews might miss.

Conclusion

NATS provides a defense-in-depth security model that addresses transport encryption, identity verification, and access control as integrated features of the messaging system. TLS encrypts all traffic. Authentication mechanisms range from simple tokens to decentralized JWT chains, suitable for environments from single-team deployments to multi-tenant platforms. Fine-grained authorization controls exactly which subjects each identity can access. Account isolation provides hard multi-tenancy boundaries. The node-nats client library supports all of these mechanisms through straightforward configuration options, making it practical to build messaging systems where security is a first-class concern rather than an afterthought.

Related Articles

business

Operating NATS in Production: Monitoring and Scaling

A practical operations guide for running NATS in production environments, covering monitoring strategies, capacity planning, scaling patterns, upgrade procedures, and incident response for engineering and platform teams.

12 min read
business

Messaging Architecture for Fintech Systems

A strategic guide to designing messaging architectures for financial technology systems, covering regulatory requirements, data consistency patterns, auditability, and the role of NATS in building compliant, resilient fintech infrastructure.

11 min read
technical

Streaming Patterns with NATS JetStream

An exploration of advanced streaming patterns using NATS JetStream, including event sourcing, CQRS, windowed aggregations, and stream processing pipelines with practical TypeScript implementations.

12 min read