Files
Joseph Doherty e553db6d40 docs: add Authentication, Clustering, JetStream, Monitoring overviews; update existing docs
New files:
- Documentation/Authentication/Overview.md — all 7 auth mechanisms with real source
  snippets (NKey/JWT/username-password/token/TLS mapping), nonce generation, account
  system, permissions, JWT permission templates
- Documentation/Clustering/Overview.md — route TCP handshake, in-process subscription
  propagation, gateway/leaf node stubs, honest gaps list
- Documentation/JetStream/Overview.md — API surface (4 handled subjects), streams,
  consumers, storage (MemStore/FileStore), in-process RAFT, mirror/source, gaps list
- Documentation/Monitoring/Overview.md — all 12 endpoints with real field tables,
  Go compatibility notes

Updated files:
- GettingStarted/Architecture.md — 14-subdirectory tree, real NatsClient/NatsServer
  field snippets, 9 new Go reference rows, Channel write queue design choice
- GettingStarted/Setup.md — xUnit 3, 100 test files grouped by area
- Operations/Overview.md — 99 test files, accurate Program.cs snippet, limitations
  section renamed to "Known Gaps vs Go Reference" with 7 real gaps
- Server/Overview.md — grouped fields, TLS/WS accept path, lame-duck mode, POSIX signals
- Configuration/Overview.md — 14 subsystem option tables, 24-row CLI table, LogOverrides
- Server/Client.md — Channel write queue, 4-task RunAsync, CommandMatrix, real fields

All docs verified against codebase 2026-02-23; 713 tests pass.
2026-02-23 10:14:18 -05:00

24 KiB

Authentication Overview

AuthService is the single entry point for client authentication. It builds an ordered chain of authenticators from NatsOptions at startup and evaluates them in priority order when a client sends a CONNECT message. Each authenticator inspects the ClientAuthContext and returns an AuthResult on success or null to pass to the next authenticator in the chain.


How Authentication Works

AuthService.Build() constructs the authenticator chain at server startup. The order matches the Go reference (see golang/nats-server/server/auth.go, configureAuthentication):

// AuthService.cs — AuthService.Build()
public static AuthService Build(NatsOptions options)
{
    var authenticators = new List<IAuthenticator>();

    // TLS certificate mapping (highest priority when enabled)
    if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
        authenticators.Add(new TlsMapAuthenticator(options.Users));

    // JWT / Operator mode
    if (options.TrustedKeys is { Length: > 0 } && options.AccountResolver is not null)
    {
        authenticators.Add(new JwtAuthenticator(options.TrustedKeys, options.AccountResolver));
        nonceRequired = true;
    }

    // Priority order: NKeys > Users > Token > SimpleUserPassword
    if (options.NKeys is { Count: > 0 })
        authenticators.Add(new NKeyAuthenticator(options.NKeys));
    if (options.Users is { Count: > 0 })
        authenticators.Add(new UserPasswordAuthenticator(options.Users));
    if (!string.IsNullOrEmpty(options.Authorization))
        authenticators.Add(new TokenAuthenticator(options.Authorization));
    if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
        authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
}

NonceRequired is set to true when JWT or NKey authenticators are active. The server includes a nonce in the INFO message before accepting CONNECT, so clients can sign it.

Authenticate() iterates the chain and returns the first non-null result. If all authenticators decline and a NoAuthUser is configured, it falls back to that user — but only when the client presented no credentials at all:

// AuthService.cs — Authenticate() and IsNoCredentials()
public AuthResult? Authenticate(ClientAuthContext context)
{
    if (!IsAuthRequired)
        return new AuthResult { Identity = string.Empty };

    foreach (var authenticator in _authenticators)
    {
        var result = authenticator.Authenticate(context);
        if (result != null)
            return result;
    }

    if (_noAuthUser != null && IsNoCredentials(context))
        return ResolveNoAuthUser();

    return null;
}

private static bool IsNoCredentials(ClientAuthContext context)
{
    var opts = context.Opts;
    return string.IsNullOrEmpty(opts.Username)
        && string.IsNullOrEmpty(opts.Password)
        && string.IsNullOrEmpty(opts.Token)
        && string.IsNullOrEmpty(opts.Nkey)
        && string.IsNullOrEmpty(opts.Sig)
        && string.IsNullOrEmpty(opts.JWT);
}

A null return from Authenticate() causes the server to reject the connection with an -ERR 'Authorization Violation' message.


Auth Mechanisms

TLS certificate mapping

TlsMapAuthenticator maps a client's TLS certificate to a configured User by matching the certificate subject Distinguished Name (DN) or Common Name (CN). This fires only when tls_map: true and tls: { verify: true } are both set alongside a users block.

// TlsMapAuthenticator.cs — Authenticate()
public AuthResult? Authenticate(ClientAuthContext context)
{
    var cert = context.ClientCertificate;
    if (cert == null)
        return null;

    var dn = cert.SubjectName;
    var dnString = dn.Name; // RFC 2253 format

    // Try exact DN match first
    if (_usersByDn.TryGetValue(dnString, out var user))
        return BuildResult(user);

    // Try CN extraction
    var cn = ExtractCn(dn);
    if (cn != null && _usersByCn.TryGetValue(cn, out user))
        return BuildResult(user);

    return null;
}

private static string? ExtractCn(X500DistinguishedName dn)
{
    foreach (var rdn in dn.Name.Split(',', StringSplitOptions.TrimEntries))
    {
        if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
            return rdn[3..];
    }
    return null;
}

CN extraction splits the RFC 2253 DN string on commas and looks for the CN= attribute. The username in the users block must match either the full DN or the CN value.

JWT / Operator mode

JwtAuthenticator validates client JWTs in operator mode. The server is configured with one or more trusted operator NKey public keys and an IAccountResolver that maps account NKey public keys to account JWTs. Validation runs nine steps before authentication succeeds:

// JwtAuthenticator.cs — Authenticate() (steps 1-7)
var userClaims = NatsJwt.DecodeUserClaims(jwt);       // 1. Decode user JWT
if (userClaims.IsExpired()) return null;               // 2. Check expiry

var accountJwt = _resolver.FetchAsync(issuerAccount)  // 3. Resolve account JWT
    .GetAwaiter().GetResult();
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);

if (!IsTrusted(accountClaims.Issuer)) return null;    // 4. Account issuer must be trusted operator

// 5. User JWT must be issued by the account or one of its signing keys
if (userIssuer != accountClaims.Subject)
{
    var signingKeys = accountClaims.Nats?.SigningKeys;
    if (signingKeys is null || !signingKeys.Contains(userIssuer))
        return null;
}

if (!userClaims.BearerToken)                          // 6. Verify nonce signature
{
    if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
        return null;
}

if (revocations.TryGetValue(userClaims.Subject, out var revokedAt))  // 7. Revocation check
    if (userClaims.IssuedAt <= revokedAt) return null;

The IAccountResolver interface decouples JWT storage from the authenticator. MemAccountResolver covers tests and simple single-operator deployments; production deployments can supply a resolver backed by a URL or directory:

// AccountResolver.cs
public sealed class MemAccountResolver : IAccountResolver
{
    private readonly ConcurrentDictionary<string, string> _accounts = new(StringComparer.Ordinal);

    public Task<string?> FetchAsync(string accountNkey)
    {
        _accounts.TryGetValue(accountNkey, out var jwt);
        return Task.FromResult(jwt);
    }

    public Task StoreAsync(string accountNkey, string jwt)
    {
        _accounts[accountNkey] = jwt;
        return Task.CompletedTask;
    }
}

NatsJwt.Decode() splits the token into header, payload, and signature segments and uses System.Text.Json to deserialize them. All NATS JWTs use the ed25519-nkey algorithm and start with eyJ (base64url for {").

NKey

NKeyAuthenticator performs Ed25519 public-key authentication without a JWT. The client sends its public NKey and a base64-encoded signature of the server nonce. The server verifies the signature using the NATS.NKeys library:

// NKeyAuthenticator.cs — Authenticate()
public AuthResult? Authenticate(ClientAuthContext context)
{
    var clientNkey = context.Opts.Nkey;
    if (string.IsNullOrEmpty(clientNkey)) return null;
    if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser)) return null;

    // Decode base64 signature (handle both standard and URL-safe base64)
    byte[] sigBytes;
    try { sigBytes = Convert.FromBase64String(clientSig); }
    catch (FormatException)
    {
        var padded = clientSig.Replace('-', '+').Replace('_', '/');
        padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
        sigBytes = Convert.FromBase64String(padded);
    }

    var kp = KeyPair.FromPublicKey(clientNkey);
    if (!kp.Verify(context.Nonce, sigBytes)) return null;

    return new AuthResult { Identity = clientNkey, AccountName = nkeyUser.Account,
                            Permissions = nkeyUser.Permissions };
}

The signature fallback handles both URL-safe and standard base64 encoding because different NATS client libraries encode signatures differently.

Username/password — multi-user

UserPasswordAuthenticator handles the users block where multiple username/password pairs are defined. It supports both plain-text and bcrypt-hashed passwords. The $2 prefix detection matches the Go server's isBcrypt() function:

// UserPasswordAuthenticator.cs — ComparePasswords()
private static bool ComparePasswords(string serverPassword, string clientPassword)
{
    if (IsBcrypt(serverPassword))
    {
        try { return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword); }
        catch { return false; }
    }

    var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
    var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
    return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
}

private static bool IsBcrypt(string password) => password.StartsWith("$2");

Plain-text passwords use CryptographicOperations.FixedTimeEquals to prevent timing attacks. Bcrypt hashes are prefixed with $2a$, $2b$, or $2y$ depending on the variant.

Username/password — single user

SimpleUserPasswordAuthenticator covers the common case of a single user/password pair in the server config. It applies constant-time comparison for both the username and password:

// SimpleUserPasswordAuthenticator.cs — Authenticate()
public AuthResult? Authenticate(ClientAuthContext context)
{
    var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername);
    if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername))
        return null;

    var clientPassword = context.Opts.Password ?? string.Empty;
    if (!ComparePasswords(_serverPassword, clientPassword))
        return null;

    return new AuthResult { Identity = clientUsername };
}

Comparing the username in constant time prevents an attacker from using response timing to enumerate valid usernames even before the password check.

Token

TokenAuthenticator matches a single opaque authorization token against the authorization config key. Comparison is constant-time to prevent length-based timing leaks:

// TokenAuthenticator.cs
public sealed class TokenAuthenticator : IAuthenticator
{
    private readonly byte[] _expectedToken;

    public TokenAuthenticator(string token)
    {
        _expectedToken = Encoding.UTF8.GetBytes(token);
    }

    public AuthResult? Authenticate(ClientAuthContext context)
    {
        var clientToken = context.Opts.Token;
        if (string.IsNullOrEmpty(clientToken)) return null;

        var clientBytes = Encoding.UTF8.GetBytes(clientToken);
        if (!CryptographicOperations.FixedTimeEquals(clientBytes, _expectedToken))
            return null;

        return new AuthResult { Identity = "token" };
    }
}

The token authenticator does not associate an account or permissions — those must be managed at the server level.

No-auth user fallback

When NoAuthUser is set in NatsOptions, clients that present no credentials at all (no username, password, token, NKey, or JWT) are mapped to that named user. The fallback only applies after all authenticators have declined. The resolution pulls the user's permissions and account assignment from the users map built during Build():

// AuthService.cs — ResolveNoAuthUser()
private AuthResult? ResolveNoAuthUser()
{
    if (_noAuthUser == null) return null;

    if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
    {
        return new AuthResult
        {
            Identity = user.Username,
            AccountName = user.Account,
            Permissions = user.Permissions,
            Expiry = user.ConnectionDeadline,
        };
    }

    return new AuthResult { Identity = _noAuthUser };
}

This pattern lets an operator define one permissive "guest" user and one or more restricted named users without requiring every client to authenticate explicitly.


Nonce Generation

When NonceRequired is true, the server generates a nonce before sending INFO and includes it in the nonce field. The client must sign this nonce with its private key and return the signature in the sig field of CONNECT.

The nonce is 11 random bytes encoded as URL-safe base64 (no padding). 11 bytes produce 15 base64 characters, which avoids padding characters entirely:

// AuthService.cs — GenerateNonce() and EncodeNonce()
public byte[] GenerateNonce()
{
    Span<byte> raw = stackalloc byte[11];
    RandomNumberGenerator.Fill(raw);
    return raw.ToArray();
}

public string EncodeNonce(byte[] nonce)
{
    return Convert.ToBase64String(nonce)
        .TrimEnd('=')
        .Replace('+', '-')
        .Replace('/', '_');
}

The raw nonce bytes (not the base64 string) are passed to ClientAuthContext.Nonce so that NKey and JWT signature verification receive the exact bytes that were sent on the wire as a base64 string but verified as the original bytes.


The Account System

Every authenticated connection belongs to an Account. Accounts provide subject namespace isolation: each Account owns a dedicated SubList, so messages published within one account never reach subscribers in another unless an explicit export/import is configured.

// Account.cs
public sealed class Account : IDisposable
{
    public const string GlobalAccountName = "$G";

    public string Name { get; }
    public SubList SubList { get; } = new();
    public int MaxConnections { get; set; } // 0 = unlimited
    public int MaxSubscriptions { get; set; } // 0 = unlimited
    public int MaxJetStreamStreams { get; set; } // 0 = unlimited
    public ExportMap Exports { get; } = new();
    public ImportMap Imports { get; } = new();
}

The $G (Global) account is the default when no multi-account configuration is present. All clients that authenticate without an explicit account name join $G.

Resource limits are enforced with atomic counters at connection and subscription time:

// Account.cs — AddClient() and IncrementSubscriptions()
public bool AddClient(ulong clientId)
{
    if (MaxConnections > 0 && _clients.Count >= MaxConnections)
        return false;
    _clients[clientId] = 0;
    return true;
}

public bool IncrementSubscriptions()
{
    if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
        return false;
    Interlocked.Increment(ref _subscriptionCount);
    return true;
}

public bool TryReserveStream()
{
    if (MaxJetStreamStreams > 0 && Volatile.Read(ref _jetStreamStreamCount) >= MaxJetStreamStreams)
        return false;
    Interlocked.Increment(ref _jetStreamStreamCount);
    return true;
}

AddClient checks the limit before inserting. The _clients dictionary uses ulong client IDs as keys so ClientCount reflects only the current live connections for that account.

User revocation is per-account and supports both individual user NKeys and a "*" wildcard that revokes all users issued before a given timestamp:

// Account.cs — IsUserRevoked()
public bool IsUserRevoked(string userNkey, long issuedAt)
{
    if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
        return issuedAt <= revokedAt;
    if (_revokedUsers.TryGetValue("*", out revokedAt))
        return issuedAt <= revokedAt;
    return false;
}

Exports and imports allow subjects to be shared between accounts. A service export makes a set of subjects callable by other accounts; a stream export allows subscriptions. Imports wire an external subject into the current account's namespace under a local alias. Both directions enforce authorization at configuration time via ExportAuth.IsAuthorized().


Permissions

Permissions defines per-client publish and subscribe rules via SubjectPermission allow/deny lists and an optional ResponsePermission for request-reply:

// Permissions.cs
public sealed class Permissions
{
    public SubjectPermission? Publish { get; init; }
    public SubjectPermission? Subscribe { get; init; }
    public ResponsePermission? Response { get; init; }
}

public sealed class SubjectPermission
{
    public IReadOnlyList<string>? Allow { get; init; }
    public IReadOnlyList<string>? Deny { get; init; }
}

public sealed class ResponsePermission
{
    public int MaxMsgs { get; init; }
    public TimeSpan Expires { get; init; }
}

ClientPermissions.Build() compiles these into PermissionSet instances backed by SubList tries, so wildcard patterns in allow/deny lists are matched using the same trie that handles subscriptions. PermissionSet.IsAllowed() evaluates allow first, then deny:

// ClientPermissions.cs — PermissionSet.IsAllowed()
public bool IsAllowed(string subject)
{
    bool allowed = true;

    if (_allow != null)
    {
        var result = _allow.Match(subject);
        allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
    }

    if (allowed && _deny != null)
    {
        var result = _deny.Match(subject);
        allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
    }

    return allowed;
}

A subject is allowed when it matches the allow list (or no allow list exists) and does not match any deny entry. Deny rules take precedence over allow rules when both match.

PermissionLruCache

Permission checks happen on every PUB and SUB command. To avoid a SubList.Match() call on every message, ClientPermissions maintains a PermissionLruCache per client for publish results:

// PermissionLruCache.cs
public sealed class PermissionLruCache
{
    private readonly int _capacity;
    private readonly Dictionary<string, LinkedListNode<(string Key, bool Value)>> _map;
    private readonly LinkedList<(string Key, bool Value)> _list = new();

    public void Set(string key, bool value)
    {
        if (_map.Count >= _capacity)
        {
            var last = _list.Last!;
            _map.Remove(last.Value.Key);
            _list.RemoveLast();
        }
        var node = new LinkedListNode<(string Key, bool Value)>((key, value));
        _list.AddFirst(node);
        _map[key] = node;
    }
}

The default capacity is 128, matching the Go server (maxPermCacheSize = 128 in client.go). Cache hits move the node to the front of the linked list; eviction removes the tail node. The cache is per-client and lock-protected, so contention is low.

Dynamic reply subjects bypass the cache. When a client sends a request with a reply subject, that subject is registered in ResponseTracker and bypasses the deny check for the configured window.

ResponseTracker

ResponseTracker maintains the set of reply subjects a client is temporarily permitted to publish to. This enables request-reply patterns for clients whose Publish permission list does not include the auto-generated _INBOX.* subject:

// ResponseTracker.cs — IsReplyAllowed()
public bool IsReplyAllowed(string subject)
{
    lock (_lock)
    {
        if (!_replies.TryGetValue(subject, out var entry))
            return false;

        if (_expires > TimeSpan.Zero && DateTime.UtcNow - entry.RegisteredAt > _expires)
        {
            _replies.Remove(subject);
            return false;
        }

        var newCount = entry.Count + 1;
        if (_maxMsgs > 0 && newCount > _maxMsgs)
        {
            _replies.Remove(subject);
            return false;
        }

        _replies[subject] = (entry.RegisteredAt, newCount);
        return true;
    }
}

Each entry tracks registration time and message count. Entries expire by TTL (_expires), by message count (_maxMsgs), or both. Prune() can be called periodically to evict stale entries without waiting for an access attempt.


JWT Permission Templates

When a user connects via JWT, permission subjects can contain mustache-style template expressions that are expanded using claim values from the user and account JWTs. This allows a single JWT template to scope permissions to specific tenants or user identities without issuing unique JWTs for every user.

PermissionTemplates.Expand() handles the expansion for a single pattern. When a template expression resolves to multiple values (e.g., a user with two dept: tags), the cartesian product of all expansions is computed:

// PermissionTemplates.cs — Expand()
public static List<string> Expand(
    string pattern, string name, string subject,
    string accountName, string accountSubject,
    string[] userTags, string[] accountTags)
{
    var matches = TemplateRegex().Matches(pattern);
    if (matches.Count == 0)
        return [pattern];

    // Compute cartesian product across all multi-value replacements
    var results = new List<string> { pattern };
    foreach (var (placeholder, values) in replacements)
    {
        var next = new List<string>();
        foreach (var current in results)
            foreach (var value in values)
                next.Add(current.Replace(placeholder, value));
        results = next;
    }
    return results;
}

Supported template functions:

Expression Resolves to
{{name()}} User's name claim
{{subject()}} User's NKey public key (sub claim)
{{tag(tagname)}} All user tag values for tagname: prefix (multi-value)
{{account-name()}} Account's name claim
{{account-subject()}} Account's NKey public key
{{account-tag(tagname)}} All account tag values for tagname: prefix (multi-value)

If a tag expression matches no tags, the entire pattern is dropped from the result list (returns empty), not expanded to an empty string. This prevents accidental wildcard grants when a user lacks the expected tag.

JwtAuthenticator calls PermissionTemplates.ExpandAll() after decoding the user JWT, before constructing the Permissions object that goes into AuthResult.


AuthResult

AuthResult carries the outcome of a successful authentication. All fields are init-only; AuthResult is produced by authenticators and consumed by the server when the connection is accepted.

// AuthResult.cs
public sealed class AuthResult
{
    public required string Identity { get; init; }
    public string? AccountName { get; init; }
    public Permissions? Permissions { get; init; }
    public DateTimeOffset? Expiry { get; init; }
    public int MaxJetStreamStreams { get; init; }
    public string? JetStreamTier { get; init; }
}
Field Purpose
Identity Human-readable identifier for the client (username, NKey public key, or "token")
AccountName The account this client belongs to. null falls back to $G.
Permissions Publish/subscribe/response restrictions. null means unrestricted.
Expiry When the connection should be terminated. null means no expiry. Derived from JWT exp or User.ConnectionDeadline.
MaxJetStreamStreams Maximum JetStream streams this client's account may create. 0 means unlimited. Set by JWT account claims.
JetStreamTier JetStream resource tier from the account JWT. Informational; used for multi-tier deployments.

Configuration

These NatsOptions fields control authentication. All fields have zero-value defaults that disable the corresponding mechanism.

NatsOptions field NATS config key Description
Username authorization.user Single username
Password authorization.password Single password (plain or bcrypt)
Authorization authorization.token Opaque auth token
Users authorization.users Multi-user list with per-user permissions
NKeys authorization.nkeys NKey user list
NoAuthUser authorization.no_auth_user Fallback user for unauthenticated clients
AuthTimeout authorization.timeout Seconds allowed for the client to send CONNECT (default 2s)
TrustedKeys operator Operator NKey public keys for JWT mode
AccountResolver (programmatic) IAccountResolver implementation for JWT account lookups
TlsVerify tls.verify Require client TLS certificates
TlsMap tls.map Map TLS certificate subject to user
Accounts accounts Per-account limits (MaxConnections, MaxSubscriptions)

Bcrypt-hashed passwords are stored in config as the full bcrypt string (e.g., $2b$11$...). The server detects the $2 prefix and delegates to BCrypt.Net.BCrypt.Verify().