Authentication & Authorization
satd has one authentication model shared by every API surface: JSON-RPC,
Esplora, the streaming APIs (events gRPC and streamws), and the MCP server.
It also keeps full backward compatibility with Bitcoin Core's cookie,
rpcuser, and rpcauth credentials.
There are two layers:
- Core-compatible operator auth: the cookie file,
-rpcuser/-rpcpassword, and-rpcauth. This is the default and behaves exactly like Bitcoin Core. A valid operator credential has full access to everything, unless Core's-rpcwhitelistlimits that user to a list of JSON-RPC methods (see Per-user method allowlists). - The unified bearer-token layer (
satd-auth): opt-in, capability-scoped bearer tokens loaded from an-authfile, each rate-limited and quota-bounded. Scoped tokens let you expose the node to partially trusted consumers, such as a BTCPay instance or a watchtower, without giving any of them operator credentials.
Note. The default is pure Bitcoin Core behavior. With no
-authfileconfigured, the bearer layer is inert: the only credentials that work are the Core-compatible ones, and every authenticated request acts as the full-capability operator. Scoped tokens are opt-in, per surface. A surface with no bearer tokens enabled does not install the capability gate at all.
How the bearer layer differs from Core-style auth
| Core-style operator auth | Unified bearer tokens | |
|---|---|---|
| Credentials | .cookie file, -rpcuser/-rpcpassword, -rpcauth (HMAC) | Opaque high-entropy tokens, sent as Authorization: Bearer <token> |
| Granularity | Full operator access, or a per-user list of JSON-RPC methods with -rpcwhitelist | Per-token capabilities (for example read-only, Esplora-only, stream-only) |
| Multi-tenant | No; one shared identity | Yes; each token has its own id, scope, quota, rate limit, and expiry |
| Rate / quota limits | None; the operator is unlimited | Per-token request rate (429/RESOURCE_EXHAUSTED) and watch-set quota |
| Where defined | Flags, bitcoin.conf, or the generated cookie | A TOML -authfile, reloadable on SIGHUP |
| Default | On (cookie auto-generated) | Off until -authfile is set and the surface opts in |
| Compatibility | Bitcoin Core wire-identical | satd extension |
Both layers coexist. On a bearer-enabled surface the operator (Basic)
credential is tried first, so existing Core tooling is not affected. A
Bearer token is consulted only when the request does not carry a valid
operator Basic credential. A matching cookie, userpass, or rpcauth
credential always resolves to the full-capability operator.
A request whose credential does not authenticate is answered 401 after a
250 ms pause and logged at warn level as
ThreadRPCServer incorrect password attempt from <ip>:<port>, as in Core. A
request with no Authorization header gets its 401 at once and is not
logged. The warning is limited to 60 lines a minute per listener; the first
line of the next minute carries a suppressed count of what it left out.
Per-user method allowlists (rpcwhitelist)
satd implements Bitcoin Core's -rpcwhitelist and -rpcwhitelistdefault
with Core's semantics, so a bitcoin.conf that restricts RPC users works
unchanged.
rpcauth=monitor:<salt>$<hash>
rpcwhitelist=monitor:getblockcount,getbestblockhash,getblockchaininfo
rpcwhitelist=<user>:<method>,<method>,...limits<user>to the listed JSON-RPC methods. Methods are separated by commas or spaces, and names are case-sensitive. The option is repeatable. A user listed more than once keeps only the methods common to every list, and that includes a user listed on both the command line and inbitcoin.conf.rpcwhitelist=<user>orrpcwhitelist=<user>:gives that user an empty list: every call is refused.rpcwhitelistdefaultdecides what users with no list may call. It defaults to 1 as soon as anyrpcwhitelistis set, which means an unlisted user may call nothing. Setrpcwhitelistdefault=0to leave unlisted users unrestricted.rpcwhitelistdefault=1with no list at all refuses every user.- The user is the name the request authenticated as. That is
__cookie__for the cookie file, the-rpcusername, or an-rpcauthname. Under a deny default, list__cookie__ifsat-cliorbitcoin-clishould keep working with the cookie.
A refused request gets HTTP 403 with an empty body, as in Core, and the
method does not run. A batch is refused whole if any call in it is not
allowed. A body that is not JSON still gets the -32700 parse error first.
The refusal is logged as RPC User <name> not allowed to call method <m>.
The allowlists apply to every JSON-RPC listener, including the read-only listener, where both filters must allow a call. Four details go beyond Core:
- WebSocket. satd's JSON-RPC listeners also accept WebSocket upgrades. A user with a list, or any user under a deny default, is refused the upgrade with 403, because calls made over an open WebSocket are not checked against the list.
- Bearer tokens are not RPC users. A token is scoped by the
capabilities in its
-authfileentry, and-rpcwhitelistdoes not apply to it, even under a deny default. -rpcdisableauthis refused together with anyrpcwhitelistor withrpcwhitelistdefault=1. The auth-disabled mTLS surface authenticates no user, so it has no name to restrict.- Reload. The lists are fixed at startup. A
SIGHUPreports a changedrpcwhitelistas needing a restart, while credentials still rotate live.
For scoping that covers Esplora, the streaming APIs and MCP, and that carries rate limits and expiry, use bearer tokens instead.
Capabilities
A bearer token carries a set of capabilities, and each surface enforces the capability it requires. Enforcement fails closed: an unknown method, or a request with no principal, requires the write capability, which neither a read-only nor a submit-only token holds.
| Capability | String | Grants |
|---|---|---|
| RPC read | rpc:read | Read-only JSON-RPC methods (classified by the same table the read-only listener uses). |
| RPC write | rpc:write | Mutating, control, and mining JSON-RPC methods, plus any unclassified method (fail-closed). Implies rpc:submit. |
| RPC submit | rpc:submit | Mempool submission: sendrawtransaction, submitpackage, and the other methods the read-only listener classes as mempool-submit; also Esplora POST /tx (with esplora:read). Lets a broadcaster (a payment processor, a wallet backend) hand transactions to the mempool without holding node control. Implied by rpc:write. |
| Esplora read | esplora:read | The Esplora REST + SSE surface. Reads only: POST /tx additionally needs rpc:submit. |
| Stream subscribe | stream:subscribe | Open a streaming subscription (events gRPC, streamws). |
| Stream watch | stream:watch | Register outpoint/script/descriptor/txid watches, bounded by the token's watch quota. |
| MCP | mcp:* | The MCP server. One capability; there is no per-tool split. |
| Test clock | test:clock | setmocktime, which moves the node clock (regtest only). Not implied by rpc:write: it reaches the future-block check, mempool expiry and block-template timestamps, so it must be granted deliberately. |
| Test net | test:net | addconnection, which opens an outbound connection of a chosen type (regtest only). Not implied by rpc:write: it dials an address the caller chooses and picks the connection's type, which decides whether that peer is asked for transactions and whether it takes part in address relay — reshaping the peer set, not writing to the node. Also sendmsgtopeer, which writes arbitrary bytes onto a peer connection, and addpeeraddress, which writes an address of the caller's choosing into the address book the node dials from. Grant it deliberately. |
The operator and loopback-trust principals hold all capabilities.
The authfile
-authfile=<path> points at a TOML file of bearer tokens. The file stores
only the SHA-256 digest of each token, never the plaintext.
version = 1
# Payment processor: reads plus broadcast, rate-capped. No node control.
[[token]]
id = "btcpay" # logging/accounting id, not the secret
hash = "sha256:<64-hex SHA-256 of the token>"
capabilities = ["rpc:read", "rpc:submit", "esplora:read"]
watch_quota = 50000 # optional; omit for unlimited
rate_limit = "200/s" # optional; omit for unlimited
# Watchtower: streaming subscribe + watch registration, expires end-2026.
[[token]]
id = "watchtower"
hash = "sha256:<64-hex>"
capabilities = ["stream:subscribe", "stream:watch"]
watch_quota = 10000
expires = 2026-12-31T00:00:00Z # unquoted RFC 3339 datetime, or unix seconds
# AI agent: full MCP tool access.
[[token]]
id = "agent"
hash = "sha256:<64-hex>"
capabilities = ["mcp:*"]
Rules:
version = 1is required. Each[[token]]needs a uniqueidand ahashof the formsha256:<64 hex>.capabilitiesdefaults to empty; such a token can authenticate but is denied everything.watch_quota,rate_limit("<n>/s"), andexpiresare optional. An omitted limit means unlimited.- An unknown capability string, a duplicate
idorhash, or a wrongversionaborts the load with an error. Nothing is ignored silently. - On Unix the file must have no group, world, or execute permission bits:
0600or0400, like a cookie file or an SSH private key. A0644or0640file is rejected. - Generate a token and its hash with, for example:
TOKEN=$(openssl rand -hex 32) # the secret you give the client printf 'sha256:%s\n' "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)" - Edit the file and send
SIGHUPto reload it. The reload swaps the token table atomically, and removing a[[token]]revokes it immediately. A parse or permission error keeps the last-good table, so a bad reload never drops auth. - Revocation and expiry reach WebSockets that are already open. A
WebSocket on the JSON-RPC listener resolves its token again for every
frame, so after a reload removes the token, or once its
expireshas passed, each call on the socket is answered in-band with error-32006(bearer token revoked or expired), and asubscribemempoolsubscription opened with it ends at its next event. A reload that changes the token's capabilities or rate limit applies to open sockets the same way.
Presenting a token
Clients send the raw token in a standard header. The scheme is case-insensitive.
Authorization: Bearer <token>
The server computes SHA-256(token), looks the digest up in the loaded
table with a constant-time guard, then checks expiry. A blank token can
never authenticate.
Quotas & rate limits
- Rate limit. A per-token token bucket (
"<n>/s", burst equal to the rate). Requests over budget are shed, never queued, so a slow or abusive consumer cannot backpressure the node. JSON-RPC, Esplora, and MCP return HTTP 429 withRetry-After; events gRPC returnsRESOURCE_EXHAUSTED;streamwsthrottles at connection time. A JSON-RPC batch is charged per call, not once per request: a batch ofncalls costsn, and a call that crosses the budget is answered in-band with error-32005whosedata.retry_after_secsmatchesRetry-After. The earlier calls in that batch are still served. A WebSocket on the JSON-RPC listener is charged the same way: the upgrade costs one, and so does every call sent over the socket after it. - Watch quota. The streaming watch-set is metered in units. One
scripthash costs one unit, and prefix watches are priced by coarseness. A
token holds units through an RAII lease, so a disconnect releases its
quota automatically. A watch add over quota is refused with an in-band
WatchAddRejectedevent naming the refused items, without tearing down the subscription.
Operator and loopback principals are unlimited.
Per-surface enablement
Bearer support is opt-in per surface: the surface flag turns it on, and it
requires -authfile. satd refuses to start if a surface flag is set without
an authfile.
| Surface | Enable flag | Capability gate | Default without the flag |
|---|---|---|---|
| JSON-RPC (read/write listeners) | -rpcauthbearer | rpc:read / rpc:submit / rpc:write | Core Basic auth (cookie/userpass/rpcauth) |
| Esplora REST / SSE | -esploraauthbearer | esplora:read (+ rpc:submit for POST /tx) | -esploraauth Basic, loopback-unauth default |
| events gRPC | -eventsgrpcauth | stream:subscribe / stream:watch | loopback-trust |
streaming WS/SSE (streamws) | -streamwsauth | stream:subscribe / stream:watch | loopback-trust |
| MCP (HTTP) | -mcpauth | mcp:* | loopback-trust |
The read-only JSON-RPC listener (-rpcreadonlybind) does not honor bearer
tokens. Client-certificate (mTLS) principals for the Electrum surface are
planned but not yet implemented.
Exposing a surface remotely
Binding the streaming or MCP surfaces to a routable address requires auth; the node refuses an unauthenticated remote bind. The chain is:
-eventsgrpcallowremote → requires -eventsgrpcauth → requires -authfile
-streamwsallowremote → requires -streamwsauth → requires -authfile
-mcpallowremote → requires -mcpauth → requires -authfile
Where a bearer token is what authenticates a remote bind, the transport must also be encrypted — the token crosses the wire on every request, and so does everything the connection carries:
-eventsgrpcallowremote + -eventsgrpcauth → requires -eventsgrpctlscert / -eventsgrpctlskey
-mcpallowremote → requires -mcpcert / -mcpkey
-eventsgrpcmtls satisfies the events-gRPC requirement on its own: it already
requires the certificate and key, and it authenticates without a token.
MCP has one further requirement that is not an authentication gate: its
transport validates the Host header against an allowlist that defaults to
loopback, so a remote MCP bind also needs -mcpallowedhost naming every
hostname clients use, or it answers 403 before auth runs. See
MCP.
-streamwsallowremote is the exception, because that transport has no TLS of
its own. A remote streamws bind sends its bearer token in cleartext unless a
TLS-terminating proxy fronts it — prefer the loopback bind plus a proxy.
For a proxy-terminated or mTLS-terminated deployment, bind to loopback and
omit the *-allow-remote flag. JSON-RPC remote exposure is governed by
Core's existing -rpcbind/-rpcallowip; there is no separate allow-remote
flag for it. -rpcallowip also gates -rpctlsbind, and
-rpcreadonlyallowip gates -rpcreadonlytlsbind, but only once the list
has at least one entry: an empty allowlist means loopback-only on a plain
bind and "no source-address rule" on a TLS bind, which is gated by TLS/mTLS
and RPC auth instead.
Transport TLS / mTLS
Native TLS and mutual TLS compose underneath this layer: mTLS gates the
connection, and a bearer token presented over it further refines the
principal's capabilities. satd terminates TLS natively on the RPC, Esplora,
and Electrum surfaces, so no sidecar is required. The *tls*/*mtls*
config keys are listed in the Configuration chapter.