A Go library for extending PocketBase for managing NATS Server using JWT-based authentication. Automatically generates and manages NATS operator, account, and user nKeys and JWTs in real-time through PocketBase hooks, eliminating traditional configuration file management.
- Real-time JWT Sync: PocketBase CRUD hooks trigger JWT generation and publish to NATS via
$SYS.REQ.CLAIMS.UPDATE - Account-Based Multi-Tenancy: NATS accounts provide hard isolation boundaries without subject scoping
- Cross-Account Communication: Account-level imports and exports for sharing streams and services between accounts
- Graceful Bootstrap: Starts without NATS running, operator JWT generated in-memory for initial NATS config
- Persistent Connections: Single NATS connection with automatic failover to backup servers and exponential backoff
- Multiple Signing Keys: Graceful and emergency key rotation per account, reissuing affected users automatically
- Two-Tier Permissions: Role baseline + optional per-user overrides (union merge), with allow/deny semantics
- Two-Tier Limits: Account-level (shared) and role-level (per-user) resource limits
- Queue-Based Publishing: Reliable JWT publishing with retry, deduplication, and automatic cleanup
- Startup Reconciliation: Republishes every active account, so a lost or rebuilt NATS resolver recovers on its own
- Reversible Suspend and Credential Rotation:
activewithdraws an account or user from NATS;revokerotates leaked user credentials - Optional At-Rest Encryption: AES-256-GCM encryption of sensitive fields using PocketBase's built-in security helpers
- Response Permissions: Request-reply pattern support with configurable limits
- Locked-Down Defaults: All collection API rules default to
nil— consuming apps explicitly grant access
go get github.com/skeeeon/pb-natspackage main
import (
"log"
"github.com/pocketbase/pocketbase"
pbnats "github.com/skeeeon/pb-nats"
)
func main() {
app := pocketbase.New()
options := pbnats.DefaultOptions()
options.NATSServerURL = "nats://localhost:4222"
options.OperatorName = "my-operator"
if err := pbnats.Setup(app, options); err != nil {
log.Fatalf("Failed to setup NATS sync: %v", err)
}
pbnats.RegisterCommands(app)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}Problem: Need operator JWT to configure NATS, but pb-nats needs NATS running.
Solution: Graceful bootstrap — JWT generated in-memory first, then exported via CLI.
./myapp serve
# Create a superuser when prompted, then stop the server (Ctrl+C)./myapp nats export --output ./nats-config/
# Creates:
# operator.jwt - Operator JWT for NATS resolver
# operator.conf - Operator config with system account
# nats.conf - Ready-to-use NATS server config
# README.txt - Setup instructionsnats-server -c ./nats-config/nats.confThe paths inside the generated config are absolute, so this works from any
working directory, and the JWT and JetStream directories are created on first
run. The config also enables a WebSocket listener on port 9222 (--websocket-port
to change it), because browsers cannot speak the NATS TCP protocol and any web
console that connects to the bus needs one.
./myapp serve
# pb-nats connects and begins syncing JWTs./myapp nats export --output ./nats-config/ # All files to directory
./myapp nats export --operator-jwt # Operator JWT to stdout
./myapp nats export --nats-conf # nats.conf to stdout
./myapp nats export --operator-conf # operator.conf to stdout
# Custom server settings
./myapp nats export --output ./nats-config/ \
--server-name my-nats \
--port 4222 \
--jetstream-store /var/lib/nats/jetstreampb_data is the entire system of record: the operator seed, every account and user key, every JWT, and the pending publish queue. Back it up and you have backed up the whole NATS trust hierarchy. Nothing outside it needs backing up — the NATS resolver directory is a cache pb-nats repopulates, and the exported config files are regenerated from the database.
If at-rest encryption is enabled, the encryption key is part of the backup. Without it the seeds in pb_data cannot be read and every credential you have issued is unrecoverable; a database backup on its own is not a backup.
-
Restore
pb_data. -
Re-run the export. This step is mandatory, not a formality:
./myapp nats export --output /srv/nats/The generated config is host-specific. nats.conf carries absolute paths for the resolver and JetStream directories, and operator.conf embeds the system account JWT. Copying an old export directory across gets both wrong — usually as a server that starts and then rejects everything.
- Start the NATS server with the regenerated config, then start PocketBase.
The resolver directory starts out empty, and that is expected. The system account is preloaded from operator.conf, which is enough for pb-nats to connect, and reconciliation then queues every active account — so tenants are republished within a debounce interval of startup. See Reconciliation on Startup.
One thing a restore cannot recover: an account deleted after the backup was taken. The restored database still contains it, so reconciliation republishes it to NATS. Delete it again. (A deletion that was still pending or had failed when the backup ran is in the restored queue and is retried on startup.)
pb-nats has no keystore and no nsc-style key export — see Non-Goals. If you need the operator seed in nsc's hands, read it out of the database directly:
sqlite3 pb_data/data.db "SELECT seed FROM nats_system_operator;"With at-rest encryption enabled, the stored value carries an enc:: prefix and is AES-256-GCM ciphertext under your encryption key rather than a usable seed.
PocketBase CRUD (REST API)
|
v
Hooks (sync/) -> Generate keys/JWTs (nkey/, jwt/)
|
v
Queue for publish (publisher/)
|
v
NATS connection with failover (connection/)
|
v
NATS Server ($SYS.REQ.CLAIMS.UPDATE)
- Collections — Creates 7 PocketBase collections (all API rules locked by default)
- NKey Manager — Generates NATS NKey pairs (operator, account, user)
- JWT Generator — Generates NATS JWTs with permissions, limits, imports, and exports
- System Components — Creates operator, system account, system role, system user
- Publisher — Starts persistent NATS connection with failover, and retries account deletions that were previously given up on
- Sync Manager — Registers PocketBase hooks for real-time sync
- Reconciliation — Queues every active account for publishing (see below)
Account JWTs live only in the NATS resolver directory. If that directory is lost, or you rebuild a server, or point pb-nats at a fresh NATS instance, the server has no record of your tenants — and because sync is hook-driven, nothing would republish them until someone happened to edit each account.
On every startup pb-nats queues an upsert for each active account. This runs through the normal publish queue, so it dedupes per account, retries, and waits out bootstrap mode when NATS is down. It's cheap on the server side too: NATS answers jwt update skipped when it already holds an equal-or-newer JWT, so accounts that are already in sync cost one no-op request each.
Inactive accounts are skipped — they're absent from NATS by design.
Deletions are handled from the other direction. A queued deletion that exhausts its retries is marked failed and would otherwise never be attempted again — and unlike an upsert it cannot be reconstructed, because the account row it refers to is already gone and the queue record's public key snapshot is the only surviving record of the intent. Startup clears that mark so the deletion is retried: whatever was rejecting it has had a whole process lifetime to change. Attempts stay capped afterwards, so this is a bounded retry per boot, not a loop.
- Account isolation for multi-tenancy (not subject scoping)
- Graceful bootstrap: operator JWT generated before NATS is running
- Two-tier permissions: role provides baseline, per-user permissions merged via union
- Two-tier limits: account-level and role-based per-user limits
- Cross-account imports/exports: managed as separate collections, embedded in account JWTs
- Multiple signing keys: most recent key signs new JWTs, older keys remain valid
- Locked collections by default: consuming app sets API rules appropriate to its deployment model
- NKeys stored in PocketBase (PocketBase is the authority, optional encryption at rest)
activeis the durable state, triggers are verbs:activesays where a record should be, edge-triggered so unrelated saves change nothing;revoke/regenerate/rotate_keysare one-shot actions that clear themselves
Deliberately not supported, to keep the surface small:
- Scoped signing keys / account
default_permissions— these pin permissions to a signing key so that whatever a user JWT claims is overridden at connect time. pb-nats generates every user JWT server-side from PocketBase state, so there is no second authority to defend against. - Operator key rotation — rotating the operator means re-signing every account JWT and redeploying
nats.confand restarting servers. It's a deliberate, hands-on procedure, not a checkbox. - Subject mappings, message tracing, connection-source (
src) and time-of-day (times) restrictions, JetStream tiered limits, per-account leafnode limits,allowed_connection_types,disallow_bearer, JetStream stream/consumer counts — real NATS features with narrow audiences, each a field and a line of generator code. Ask if you need one rather than carrying them all. - Preloading account JWTs into the exported config (
nsc generate config --mem-resolver) — a second write path into the resolver directory, where a config exported months ago could push stale account JWTs, and their stale revocation lists, back over newer ones on the next server start. Reconciliation already repopulates an empty resolver at boot, which is the case preloading would be for. - Pruning NATS to match PocketBase (
nsc push --prune) — only safe where PocketBase is the sole authority for that resolver. Share the server with a second pb-nats instance, or one hand-made nsc account, and prune deletes it. The gap it would cover — a deletion given up on — is closed at startup instead, by retrying queued deletions. - A keystore, key export, or adopting an existing nsc operator —
pb_datais the backup and it holds the only copy of the operator seed, so a key export adds a second thing to lose rather than a safety net (and it could not survive a lost encryption key either). For the escape-hatch case, read the seed out of the database: see Extracting the Operator Seed. - Automatic JWT renewal — see below.
pb-nats creates 7 collections. All have nil API rules by default — the consuming app must explicitly configure access rules appropriate for its deployment.
Internal collection — should remain locked. Contains the operator identity, signing keys, and JWT used for NATS server configuration.
| Field | Type | Hidden | Description |
|---|---|---|---|
name |
Text | Operator name | |
public_key |
Text | Operator identity public key | |
private_key |
Text | Yes | Operator private key |
seed |
Text | Yes | Operator seed |
signing_keys |
JSON | Array of signing public keys | |
signing_keys_private |
JSON | Yes | Array of signing key material |
jwt |
Text | Operator JWT | |
system_account_id |
Text | Reference to system account record |
Each account is an isolation boundary in NATS. Users within an account cannot see traffic from other accounts.
| Field | Type | Hidden | Description |
|---|---|---|---|
name |
Text | Account display name | |
description |
Text | Account description | |
public_key |
Text | Account public key | |
private_key |
Text | Yes | Account private key |
seed |
Text | Yes | Account seed |
signing_keys |
JSON | Array of signing public keys | |
signing_keys_private |
JSON | Yes | Array of signing key material |
jwt |
Text | Account JWT | |
revocations |
JSON | Map of revoked user public keys to unix-second cutoff | |
active |
Bool | Presence in NATS — clearing it withdraws the account (see Suspending an Account) | |
add_signing_key |
Bool | Trigger: append new signing key | |
remove_signing_key |
Text | Trigger: remove key by public key string | |
rotate_keys |
Bool | Trigger: emergency rotation (purge all, generate new) | |
max_connections |
Number | Max concurrent connections (-1=unlimited, 0=disabled) | |
max_subscriptions |
Number | Max subscriptions (-1=unlimited, 0=disabled) | |
max_data |
Number | Max bytes in-flight (-1=unlimited, 0=disabled) | |
max_payload |
Number | Max message size (-1=unlimited, 0=disabled) | |
max_jetstream_disk_storage |
Number | JetStream disk limit (-1=unlimited, 0=disabled) | |
max_jetstream_memory_storage |
Number | JetStream memory limit (-1=unlimited, 0=disabled) |
Declares subjects that an account makes available to other accounts. Supports both streams (continuous data flow) and services (request-reply).
| Field | Type | Description |
|---|---|---|
account_id |
Relation | Owning account (cascade delete) |
name |
Text | Export name |
subject |
Text | NATS subject pattern (supports wildcards) |
type |
Select | stream or service |
token_req |
Bool | Require activation token for import |
response_type |
Select | Singleton, Stream, or Chunked (service only) |
response_threshold |
Number | Response timeout in milliseconds (service only) |
account_token_position |
Number | Position of account token in wildcard subject |
advertise |
Bool | Advertise this export |
allow_trace |
Bool | Allow trace (service only) |
description |
Text | Export description |
Consumes subjects exported by other accounts. The exporting account is referenced by public key, not by relation, since it may be in a different deployment.
| Field | Type | Description |
|---|---|---|
account_id |
Relation | Importing account (cascade delete) |
name |
Text | Import name |
subject |
Text | Subject being imported |
account |
Text | Exporting account's public key |
token |
Text | Activation JWT (required when export has token_req) |
local_subject |
Text | Local subject remapping (supports $1, $2 references) |
type |
Select | stream or service |
share |
Bool | Enable latency tracking (service only) |
allow_trace |
Bool | Allow trace (stream only) |
description |
Text | Import description |
Permission templates assigned to users. Defines allowed/denied subjects and per-user resource limits.
| Field | Type | Description |
|---|---|---|
name |
Text | Role name |
description |
Text | Role description |
is_default |
Bool | Default role flag |
publish_permissions |
JSON | Allowed publish subjects |
subscribe_permissions |
JSON | Allowed subscribe subjects |
publish_deny_permissions |
JSON | Denied publish subjects (takes precedence) |
subscribe_deny_permissions |
JSON | Denied subscribe subjects (takes precedence) |
allow_response |
Bool | Enable request-reply response permissions |
allow_response_max |
Number | Max responses per request (-1=unlimited, 0=default/1) |
allow_response_ttl |
Number | Response TTL in seconds (0=no limit) |
max_subscriptions |
Number | Per-user subscription limit |
max_data |
Number | Per-user data limit |
max_payload |
Number | Per-user message size limit |
PocketBase auth collection with NATS integration. Each user belongs to one account and one role.
| Field | Type | Hidden | Description |
|---|---|---|---|
nats_username |
Text | NATS username | |
description |
Text | User description | |
account_id |
Relation | Link to account | |
role_id |
Relation | Link to role | |
public_key |
Text | User public key | |
private_key |
Text | Yes | User private key |
seed |
Text | Yes | User seed |
jwt |
Text | User JWT | |
creds_file |
Text | Complete NATS .creds file for client connection | |
bearer_token |
Bool | Enable bearer token auth | |
jwt_expires_at |
Date | JWT expiration timestamp (renewal is the client's job — see JWT Expiry) | |
regenerate |
Bool | Trigger: regenerate JWT | |
revoke |
Bool | Trigger: rotate credentials, killing the leaked ones (user stays active) | |
active |
Bool | Durable suspend switch — clearing it revokes and does not reissue | |
publish_permissions |
JSON | Per-user publish overrides (merged with role) | |
subscribe_permissions |
JSON | Per-user subscribe overrides (merged with role) | |
publish_deny_permissions |
JSON | Per-user publish deny overrides | |
subscribe_deny_permissions |
JSON | Per-user subscribe deny overrides |
Internal queue for reliable JWT publishing. Should remain locked.
| Field | Type | Description |
|---|---|---|
account_id |
Relation | Account being published |
action |
Select | upsert or delete |
message |
Text | Error message on failure |
attempts |
Number | Retry count (0-10) |
failed_at |
Date | Set on permanent failure |
NATS accounts are isolated by default. Imports and exports enable controlled cross-account communication without breaking isolation boundaries.
An export declares a subject that other accounts can access. Two types:
- Stream: The exporting account publishes data, importing accounts subscribe. One-way data flow.
- Service: The importing account sends requests, the exporting account responds. Request-reply pattern.
POST /api/collections/nats_account_exports/records
{
"account_id": "ACCOUNT_RECORD_ID",
"name": "sensor-data",
"subject": "sensors.>",
"type": "stream"
}Service export with response configuration:
POST /api/collections/nats_account_exports/records
{
"account_id": "ACCOUNT_RECORD_ID",
"name": "auth-service",
"subject": "auth.validate",
"type": "service",
"response_type": "Singleton"
}An import consumes a subject exported by another account. The exporting account is referenced by its public key.
POST /api/collections/nats_account_imports/records
{
"account_id": "IMPORTING_ACCOUNT_RECORD_ID",
"name": "sensor-data",
"subject": "sensors.>",
"account": "AABC...EXPORTING_ACCOUNT_PUBLIC_KEY",
"type": "stream"
}Import with local subject remapping:
POST /api/collections/nats_account_imports/records
{
"account_id": "IMPORTING_ACCOUNT_RECORD_ID",
"name": "auth-service",
"subject": "auth.validate",
"account": "AABC...EXPORTING_ACCOUNT_PUBLIC_KEY",
"type": "service",
"local_subject": "external.auth.validate"
}For restricted access, set token_req: true on the export. Importing accounts must provide an activation token (JWT) in the import's token field.
Exports and imports are embedded in the account JWT. When you create, update, or delete an export or import record, the owning account's JWT is automatically regenerated and published to NATS. No manual intervention required.
All collections default to nil (no API access). This is intentional — pb-nats is a library, and the consuming app is responsible for setting API rules appropriate for its deployment model.
Recommended approach:
- Keep
nats_system_operatorandnats_publish_queuelocked (system-only) - Set accounts, exports, and imports rules to allow trusted admin/service access
- Set roles rules to allow trusted admin/service access
- Set user rules to allow self-service credential retrieval
Example (consuming app's migration or setup code):
accounts, _ := app.FindCollectionByNameOrId("nats_accounts")
accounts.ListRule = types.Pointer("@request.auth.id != '' && active = true")
accounts.ViewRule = types.Pointer("@request.auth.id != '' && active = true")
app.Save(accounts)Hidden Fields
Sensitive cryptographic material (private_key, seed, signing_keys_private) is marked Hidden: true on all collections. These fields are never included in API responses, regardless of access rules.
Some deletions have no repair path, so pb-nats refuses them at the API layer — including for a superuser in the admin UI:
| Record | Why |
|---|---|
The nats_system_operator row |
Its seed is the root of trust for every account and user JWT, and it exists nowhere else — there is no keystore and no key export. Deleting it orphans every credential ever issued, with no recovery short of restoring the database. |
| The system account | It is the account pb-nats itself connects through. |
The system user (sys) |
It authenticates pb-nats' own NATS connection; JWT synchronization stops until the process restarts. |
| A role users still reference | role_id is a required relation with no cascade, so deleting a role in use leaves its users pointing at a row that is gone. Nothing fails at delete time — the next JWT regeneration then fails for every one of them, and a user whose role can't be resolved can no longer be reissued at all. Reassign them first. |
These guards are request-scoped: they cover the REST API and the admin UI, not a deliberate app.Delete() from the consuming application, which is assumed to know what it is doing.
Optional AES-256-GCM encryption of sensitive fields stored in the database, using PocketBase's built-in security helpers.
options := pbnats.DefaultOptions()
options.EncryptionKey = "your-random-32-character-string!" // exactly 32 charactersEncrypted fields: private_key, seed, and signing_keys_private on operator, account, and user records.
Not encrypted (by design):
jwt— public claims, needed for NATS resolver configcreds_file— contains seed but left unencrypted for self-service downloadpublic_key— not sensitive
Backward compatibility: Encrypted values are stored with an enc:: prefix. Values without the prefix are treated as plaintext. Existing unencrypted data works without migration — fields are encrypted on the next write.
Key requirements: Must be exactly 32 characters (AES-256). Validated at startup. Changing the key requires manual re-encryption of existing data.
Accounts and the operator support multiple signing keys. The most recently added key signs new JWTs, while older keys remain valid for existing JWTs.
Add key (graceful rotation):
PATCH /api/collections/nats_accounts/records/{id}
{"add_signing_key": true}Remove key:
PATCH /api/collections/nats_accounts/records/{id}
{"remove_signing_key": "AABC...public_key_to_remove"}Emergency rotation (purge all, generate new):
PATCH /api/collections/nats_accounts/records/{id}
{"rotate_keys": true}Graceful rotation workflow:
{"add_signing_key": true}— new key added, old JWTs still valid- Regenerate user JWTs at your own pace via
{"regenerate": true}on each user {"remove_signing_key": "OLD_KEY"}— revoke the old key
Removing a key reissues the account's users automatically. NATS validates a user JWT by looking its issuer up in the account's signing keys and rejects it outright when the key is gone — no revocation entry required. So whenever an update removes a signing key (remove_signing_key or rotate_keys), every active user in that account gets a fresh JWT and creds_file, and clients must download the new credentials. Adding a key removes nothing and so reissues nothing.
Emergency rotation also clears the account's revocation list: every JWT those entries covered was signed by a key that no longer exists, so the entries can never matter again and only bloat the account JWT.
An account's active flag is its presence in NATS, and it is edge-triggered — only a change to the flag moves the account.
PATCH /api/collections/nats_accounts/records/{id}
{"active": false}This withdraws the account from NATS via $SYS.REQ.CLAIMS.DELETE. The server zeroes the account's connection, subscription, payload and leafnode limits, disconnects its connected clients, and disables its JetStream. Setting active: true again republishes the JWT and the server restores all of it, which is what makes this a reversible suspend rather than a destructive delete — the PocketBase record, its keys, and its exports and imports are untouched throughout.
An account created with active: false is never published in the first place.
Requires
allow_delete: truein the resolver block. The generatednats.confsets this.
User JWTs are bearer credentials the client holds (in creds_file); they are not pushed to NATS, so deleting or editing a user record does not, on its own, stop those credentials from working. Revocation is the surgical tool for invalidating already-distributed credentials without rotating the whole account signing key (which would invalidate every user in the account).
Revocation is tracked on the account (revocations) as a map of user public key to a unix-second cutoff, embedded in the account JWT. NATS rejects any user JWT for that key issued at or before the cutoff. Entries are permanent — they are never auto-cleared, because a revoked creds_file with no expiry stays valid forever and the entry is the only thing rejecting it. Dropping entries by age would hand those credentials back.
The list therefore grows with revocations, and each entry costs roughly 100 bytes of the account JWT (capped at 50000 characters, shared with exports, imports and signing keys) — on the order of 450 revocations before it becomes a concern. Emergency rotation clears the list outright. If you ever churn credentials fast enough to approach the ceiling, the next step is jwt.All wildcard compaction: revoke everything issued before a cutoff with a single entry and reissue the account's active users. That isn't implemented — ask if you need it.
There are two ways to invalidate a user's credentials, for two different situations.
revoke — the credentials leaked, but the user stays. "The laptop was stolen; they still work here."
PATCH /api/collections/nats_users/records/{id}
{"revoke": true}This rotates the user's entire key pair: a new seed and public key are generated, the old public key is revoked on the account, and a fresh JWT and creds_file are issued. The user remains active and can pick up working credentials immediately. The key pair is replaced rather than reused because whoever holds the leaked creds_file also holds the seed — reissuing for the same key would leave compromised material in play.
active: false — suspend the user. "They left the company."
PATCH /api/collections/nats_users/records/{id}
{"active": false}This revokes the user's current key and deliberately does not reissue. The user has no working credentials until reactivated. The flag is edge-triggered, so unrelated edits to a suspended user don't churn anything — and neither does editing their role, since role changes skip suspended users rather than reissuing them.
Reactivate:
PATCH /api/collections/nats_users/records/{id}
{"active": true}A fresh JWT is issued automatically. It carries a later issue time than the revocation cutoff, so NATS accepts it while the older, already-distributed credentials stay permanently revoked (the cutoff is never removed, so old creds can't be resurrected).
Deleting a user revokes their key automatically — no extra step needed.
activegoverns transitions, not creation. A user created withactive: falsestill gets a working JWT andcreds_file— there is nothing distributed yet to revoke, and PocketBase bool fields default tofalse, so treating creation as a suspend would break every caller that simply omits the field. To provision a user without usable credentials, create them and then clearactive. (Accounts differ: an account created inactive is never published to NATS at all, because presence there is a single yes-or-no fact about the account.)
Note: Because the cutoff has one-second resolution, a JWT reissued in the same second as a revocation is also treated as revoked. This is a non-issue for human-driven actions, which are always well over a second apart — and
revokeavoids the question entirely by issuing against a new public key that no cutoff covers.
Expiry is opt-in and renewal is the client's responsibility — pb-nats does not sweep for expiring JWTs or reissue them on a timer.
Set it per user with jwt_expires_at, or deployment-wide with DefaultJWTExpiry (default 0, never expires). A per-user date takes precedence over the deployment default.
The tools for a client-driven renewal loop are already in place: a client whose NATS credentials have expired can still authenticate to PocketBase with its email and password, so it can call your rotation endpoint (which sets regenerate: true) and download the fresh creds_file. Expose that however suits your deployment.
The system user is exempt from expiry, both from
DefaultJWTExpiryand from an explicitjwt_expires_at. Its JWT authenticates pb-nats' own persistent connection to NATS, and nothing renews it — an expiry there would silently stop all JWT synchronization the moment it elapsed.
Permissions follow NATS semantics: deny takes precedence over allow.
- Check if subject matches any Allow pattern
- If allowed, check if subject matches any Deny pattern
- Deny wins on match
{
"name": "sensor_reader",
"publish_permissions": ["sensors.>"],
"subscribe_permissions": ["sensors.>", "alerts.>"],
"publish_deny_permissions": ["sensors.internal.>"],
"subscribe_deny_permissions": ["alerts.admin.>"]
}User-level permissions are merged (union) with role permissions — they extend the role's baseline, not replace it.
{
"publish_permissions": ["admin.reports.>"],
"subscribe_permissions": ["admin.reports.>"]
}If the role grants sensors.>, the user's resulting JWT contains both sensors.> and admin.reports.>.
Per-user deny permissions also merge with role deny permissions. When all permission fields are empty, the user inherits the role's permissions unchanged.
For request-reply patterns, configure on the role:
{
"allow_response": true,
"allow_response_max": 1,
"allow_response_ttl": 30
}allow_response_max: -1=unlimited, 0=default (1 response), positive=specific limitallow_response_ttl: 0=no expiration, positive=seconds
Set on the account record. Controls total resources across all users in the account:
max_connections,max_subscriptions,max_data,max_payloadmax_jetstream_disk_storage,max_jetstream_memory_storage
Set on the role record. Controls individual user resource usage:
max_subscriptions,max_data,max_payload
| Value | Meaning |
|---|---|
-1 |
Unlimited |
0 |
Disabled (blocks access entirely) |
| positive | Specific limit (bytes, count, etc.) |
Setting a limit to 0 completely disables access for that resource.
options := pbnats.DefaultOptions()
// NATS server
options.NATSServerURL = "nats://localhost:4222"
options.BackupNATSServerURLs = []string{"nats://backup1:4222", "nats://backup2:4222"}
options.OperatorName = "my-operator"
// Custom collection names (optional)
options.AccountCollectionName = "nats_accounts"
options.UserCollectionName = "nats_users"
options.RoleCollectionName = "nats_roles"
options.ExportCollectionName = "nats_account_exports"
options.ImportCollectionName = "nats_account_imports"
// Connection retry
options.ConnectionRetryConfig = &pbnats.RetryConfig{
MaxPrimaryRetries: 4, // attempts before trying backup
InitialBackoff: 1 * time.Second,
MaxBackoff: 8 * time.Second,
BackoffMultiplier: 2.0,
FailbackInterval: 30 * time.Second, // how often to try primary again
}
// Timeouts
options.ConnectionTimeouts = &pbnats.TimeoutConfig{
ConnectTimeout: 5 * time.Second,
PublishTimeout: 10 * time.Second,
RequestTimeout: 10 * time.Second,
}
// Performance
options.PublishQueueInterval = 30 * time.Second // queue processing interval
options.DebounceInterval = 3 * time.Second // batch rapid changes
// Cleanup
options.FailedRecordCleanupInterval = 6 * time.Hour
options.FailedRecordRetentionTime = 24 * time.Hour
// Default permissions (when role permissions are empty)
options.DefaultPublishPermissions = []string{">"}
options.DefaultSubscribePermissions = []string{">", "_INBOX.>"}
// JWT
options.DefaultJWTExpiry = 0 // never expires
// Security
options.EncryptionKey = "" // empty = disabled, 32 chars = enabled
// Event filtering
options.EventFilter = func(collection, event string) bool {
return true // process all events
}pbnats.RegisterCommandsWithOptions(app, pbnats.CommandOptions{
DefaultServerName: "my-nats-server",
DefaultPort: 4222,
DefaultJetstreamStore: "/var/lib/nats/jetstream",
DefaultOutputDir: "./nats-config",
})const pb = new PocketBase('http://localhost:8090');
await pb.collection('nats_users').authWithPassword('user@example.com', 'password');
const user = await pb.collection('nats_users').getOne(pb.authStore.record.id);
import { connect, credsAuthenticator } from 'nats';
const nc = await connect({
servers: ["nats://your-server:4222"],
authenticator: credsAuthenticator(new TextEncoder().encode(user.creds_file))
});pb-nats exports typed errors with classification helpers:
if pbnats.IsTemporaryError(err) {
// Network/timeout - retry
}
if pbnats.IsPermanentError(err) {
// Invalid config/auth - don't retry
}Bootstrap: If NATS isn't running, pb-nats operates in bootstrap mode. Export config with nats export, start NATS, then restart PocketBase.
Permission issues: Check deny permissions (both role and user level). Deny takes precedence. Per-user permissions are additive (union), not replacements.
Signing key issues: After rotate_keys, all user JWTs are invalid — regenerate explicitly. After remove_signing_key, only JWTs signed by that key are invalidated.
Resource limits: -1 = unlimited, 0 = disabled (blocks access), positive = specific limit.
Encryption: If you enable encryption on an existing deployment, data encrypts on next write. Changing the key without re-encryption breaks reads of previously encrypted data.
Cross-account imports/exports: Changes to export or import records automatically regenerate the owning account's JWT and publish it to NATS. The exporting account's public key (not record ID) is used in imports, so external accounts from other deployments are supported.
MIT License - see LICENSE file for details.