Node service-account JWTs
memQL cluster nodes authenticate to NodeService.Stream using an
identity-issued class="node" JWT. Closes
threat-model §5.1 /
#105.
Token shape
A node JWT is a regular identity-issued EdDSA-signed JWT plus three extra claims:
| Claim | Value |
|---|---|
class | "node" (the surface pin) |
node_id | The v1:cluster:node.id the token binds to |
node_type | The build-tag-derived role (bff, voice, cognition, agent, planner, workbench) |
sub | The v1:identity:identity.id of the underlying credential row |
The token is signed with the same EdDSA key as user-class JWTs, so the per-node verifier validates both via the same JWKS endpoint.
Surface pinning
The NodeService.Stream interceptor admits a class=node JWT and
rejects every other shape:
- Non-JWT source (PATs) → rejected (PATs can't speak the mesh).
Class != "node"→ rejected (user-class JWTs can't speak the mesh).NodeId == ""orNodeType == ""→ rejected (a class=node token with no binding is malformed).- After admission,
NodeHello.NodeId/NodeTypemust match the token's claims; mismatch returnsNodeShutdownand disconnects.
When the per-node verifier isn't configured (single-node dev / binaries with no identity service) the interceptor is a no-op pass-through; the mesh runs unauthenticated and the BFF-only run doesn't need a token.
Provisioning
Each node binary needs one provisioned token, copied into its
MEMQL_NODE_TOKEN env var before startup.
- Reserve a
v1:cluster:node.idfor the binary (e.g.v1:cluster:node:cognition-1). The token'snode_idclaim binds to it; rotation reuses the same id. - Mint a
v1:identity:identityrow withidentityType="node_token"and the credential variant fields:nodeId→ the reserved cluster-node idnodeType→ the build-tag stringkeyHash→ SHA-256 of the plain tokenmintedBy→ admin user id (audit)expiresAt→ defaultnow + 30d
- Sign a
class="node"JWT viaJWTIssuer.IssueNodeAccessToken(NodeIssueInput{...}). The plain compact-form bearer is returned ONCE. - Copy the bearer into the target binary's
MEMQL_NODE_TOKENenv var. The binary attachesAuthorization: Bearer ${MEMQL_NODE_TOKEN}on every outboundNodeService.Streamdial.
Rotation
Node tokens have a 30-day default TTL
(DefaultNodeTokenTTLSeconds) and no refresh path:
- Mint a fresh node JWT for the same
node_id+node_type. - Update the target binary's
MEMQL_NODE_TOKENenv var. - Restart the binary. The outbound dialer presents the new bearer; the remote interceptor accepts it; the old token's remaining TTL drains harmlessly.
For "compromised token, kill it NOW" flows, soft-delete the identity
row (active=false). The verifier's per-stream revocation watcher
(#106) catches subsequent calls within
IDENTITY_VERIFIER_REVOCATION_CHECK_SECONDS (default 5 min).
Out of scope
- Automated provisioning CLI. Two-call sequence for now.
- TLS on
NodeService.Stream. The interceptor + token pin defends against forged peers; mTLS at the transport layer is a separate hardening item. - Per-token revocation epoch. Node tokens piggyback on the existing user-row epoch (#106). A dedicated node-row epoch would let ops kill a specific compromised node token without touching every user's tokens.