Skip to content

Repository files navigation

PocketBase NATS JWT Authentication

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.

Key Features

  • 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: active withdraws an account or user from NATS; revoke rotates 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

Installation

go get github.com/skeeeon/pb-nats

Quick Start

package 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)
    }
}

Bootstrap Process

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.

Step 1: Initialize PocketBase

./myapp serve
# Create a superuser when prompted, then stop the server (Ctrl+C)

Step 2: Export NATS Configuration

./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 instructions

Step 3: Start NATS Server

nats-server -c ./nats-config/nats.conf

The 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.

Step 4: Start PocketBase

./myapp serve
# pb-nats connects and begins syncing JWTs

CLI Export Options

./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/jetstream

Backup and Restore

pb_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.

Restoring onto a Different Host

  1. Restore pb_data.

  2. 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.

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

Extracting the Operator Seed

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.

Architecture

Data Flow

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)

Initialization Order

  1. Collections — Creates 7 PocketBase collections (all API rules locked by default)
  2. NKey Manager — Generates NATS NKey pairs (operator, account, user)
  3. JWT Generator — Generates NATS JWTs with permissions, limits, imports, and exports
  4. System Components — Creates operator, system account, system role, system user
  5. Publisher — Starts persistent NATS connection with failover, and retries account deletions that were previously given up on
  6. Sync Manager — Registers PocketBase hooks for real-time sync
  7. Reconciliation — Queues every active account for publishing (see below)

Reconciliation on Startup

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.

Key Design Decisions

  • 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)
  • active is the durable state, triggers are verbs: active says where a record should be, edge-triggered so unrelated saves change nothing; revoke / regenerate / rotate_keys are one-shot actions that clear themselves

Non-Goals

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.conf and 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 operatorpb_data is 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.

Collections

pb-nats creates 7 collections. All have nil API rules by default — the consuming app must explicitly configure access rules appropriate for its deployment.

System Operator (nats_system_operator)

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

Accounts (nats_accounts)

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)

Account Exports (nats_account_exports)

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

Account Imports (nats_account_imports)

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

Roles (nats_roles)

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

Users (nats_users)

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

Publish Queue (nats_publish_queue)

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

Cross-Account Communication

NATS accounts are isolated by default. Imports and exports enable controlled cross-account communication without breaking isolation boundaries.

Exports

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"
}

Imports

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"
}

Token-Required Exports

For restricted access, set token_req: true on the export. Importing accounts must provide an activation token (JWT) in the import's token field.

How It Works

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.

Security

Collection Access Rules

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_operator and nats_publish_queue locked (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.

Protected Records

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.

At-Rest Encryption

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 characters

Encrypted 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 config
  • creds_file — contains seed but left unencrypted for self-service download
  • public_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.

Multiple Signing Keys

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:

  1. {"add_signing_key": true} — new key added, old JWTs still valid
  2. Regenerate user JWTs at your own pace via {"regenerate": true} on each user
  3. {"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.

Suspending an Account

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: true in the resolver block. The generated nats.conf sets this.

Revoking Users

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.

active governs transitions, not creation. A user created with active: false still gets a working JWT and creds_file — there is nothing distributed yet to revoke, and PocketBase bool fields default to false, 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 clear active. (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 revoke avoids the question entirely by issuing against a new public key that no cutoff covers.

JWT Expiry and Renewal

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 DefaultJWTExpiry and from an explicit jwt_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.

Permission System

Allow/Deny Semantics

Permissions follow NATS semantics: deny takes precedence over allow.

  1. Check if subject matches any Allow pattern
  2. If allowed, check if subject matches any Deny pattern
  3. Deny wins on match
{
  "name": "sensor_reader",
  "publish_permissions": ["sensors.>"],
  "subscribe_permissions": ["sensors.>", "alerts.>"],
  "publish_deny_permissions": ["sensors.internal.>"],
  "subscribe_deny_permissions": ["alerts.admin.>"]
}

Per-User Permission Overrides

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.

Response Permissions

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 limit
  • allow_response_ttl: 0=no expiration, positive=seconds

Resource Limits

Account-Level (Shared)

Set on the account record. Controls total resources across all users in the account:

  • max_connections, max_subscriptions, max_data, max_payload
  • max_jetstream_disk_storage, max_jetstream_memory_storage

User-Level (Per-User)

Set on the role record. Controls individual user resource usage:

  • max_subscriptions, max_data, max_payload

Limit Values

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.

Configuration

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
}

CLI Command Options

pbnats.RegisterCommandsWithOptions(app, pbnats.CommandOptions{
    DefaultServerName:     "my-nats-server",
    DefaultPort:           4222,
    DefaultJetstreamStore: "/var/lib/nats/jetstream",
    DefaultOutputDir:      "./nats-config",
})

Client Connection

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))
});

Error Handling

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
}

Troubleshooting

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.

License

MIT License - see LICENSE file for details.

About

A library for integration between PocketBase and NATS Server using JWT-based authentication. This library automatically generates and manages NATS JWTs in real-time, eliminating the need for traditional configuration files.

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages