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.
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().