ADR-0059: Client token signing keys, revocation, refresh rotation and permissions
| Status | Accepted |
| Date | 2026-10-13 |
| Issue | #52 |
| Pull request | #158 |
| Related | ADR-0013, ADR-0020, ADR-0026, ADR-0050 |
Context and problem statement
Converger tokens (Converger.Auth.ConvergerToken) are the end-user credential of the client API, the WebSocket,
the native Protocol v1 endpoint, SSE and gRPC. Before this decision they were stateless 30-minute HS256 JWTs signed
with SECRET_KEY_BASE:
- No revocation. A leaked token stayed valid until it expired, and
POST /tokens/refreshminted a new one from any valid token, so a leaked token could be kept alive forever.GET /conversations/:iddid the same without even calling refresh: it returned a fresh 30-minute conversation token on every call. - No rotation or reuse detection on refresh, so a stolen copy was indistinguishable from the real client.
- One key for everything. Tokens and admin/portal session cookies shared
SECRET_KEY_BASE; rotating it logged out every WebSocket client, and there was nokidto roll a key over. - All or nothing. Every token could read, post and type in its conversation; a read-only viewer token was
impossible. The existing
scopeclaim only means "follow the whole channel" (agent console).
The issue's acceptance criterion: a revoked token is rejected by REST and WebSocket within one second across nodes. The admin and portal half of #52 (2FA, password reset, sessions) is a separate decision.
Decision drivers
- Revocation visible on every node within a second, including live sockets.
- No per-request database query: token checks are on the hot path of every REST call and socket connect.
- No new infrastructure (no Redis requirement); work in a single node and in a libcluster cluster.
- Existing clients and SDKs keep working: tokens without new claims keep their meaning, the upgrade does not log anyone out.
- Detect a stolen token being used in parallel with the real client.
Considered options
Revocation store
- Postgres table + per-node ETS cache + PubSub fan-out + periodic resync.
- Redis denylist checked per request.
- Database query per verification.
- Short token lifetimes only (no revocation).
Permissions
- New
permsclaim (list ofread,write,typing);scopekeeps its meaning. - Overload
scopeas an OAuth-style space-separated list ("channel read write").
Refresh semantics
- Rotation with a grace period, reuse after the grace period revokes the family.
- Strict rotation: the old token is invalid immediately, any later use revokes the family.
- No rotation, revocation only.
Pros and cons of the options
Postgres + ETS + PubSub (chosen)
- Good, because checks are ETS lookups; the broadcast reaches other nodes in milliseconds; the periodic resync and the full load on start repair a missed broadcast. Same shape as the cluster rate limiter (ADR-0013).
- Good, because the table's primary key makes rotation atomic across nodes (exactly one of two concurrent refreshes inserts the row), which reuse detection needs.
- Bad, because a node that boots while the database is down starts with an empty cache and accepts tokens revoked elsewhere until its next sync.
- Bad, because every refresh writes a row (rows live 31 minutes).
Redis denylist
- Good, because it is the textbook approach.
- Bad, because Redis is optional in Converger (only the
redis_streamschannel uses it) and a per-request round trip is slower than ETS. Rejected.
Query per verification
- Bad, because it adds a query to every request and every socket connect, the opposite of ADR-0044. Rejected.
Short lifetimes only
- Bad, because the refresh loophole keeps a leaked token alive regardless of lifetime. Rejected.
perms claim vs. overloading scope
scope: "channel"is already issued, checked in several places and sent by the SDKs. Turning it into a list would change its meaning for existing tokens and clients. A separatepermsclaim is additive: no claim means every permission, as before.
Grace period vs. strict rotation
- Strict rotation breaks legitimate clients: a request already in flight with the old token, or two tabs sharing a token that refresh at once, would revoke the family and log the user out. A short grace period (30 s) keeps those working, and reuse after it is still detected. The cost is a 30 s window in which a stolen copy also works.
Decision
- Keys.
TOKEN_SIGNING_KEYSlists{kid, secret}pairs; the first signs and puts itskidin the header, all verify. Without it,SECRET_KEY_BASEsigns under kiddefault. Tokens without akid(issued before this change) verify withSECRET_KEY_BASEwhileTOKEN_ACCEPT_UNKEYEDis true. Legacy tokens are untouched. - Claims. Every token gets a
jtiand a family idfam(thejtiof the generated token). Derived tokens (POST /conversations,GET /conversations/:id) inheritfam,user_idandpermsand never outlive the token that asked for them (expcapped), which closes theGET /conversations/:idloophole. - Revocation store.
revoked_tokensrows:fam:<family>,user:<channel_id>:<user_id>(rejects tokens withiatup to the revocation),jti:<jti>with kindrotated. Cached per node inConverger.Auth.RevocationCache(ETS), written locally before the API answers, broadcast onConverger.PubSub, re-read incrementally every 60 s, fully loaded on start, pruned daily once expired.ConvergerToken.verify_token/2checks it on every verification, so REST, the Phoenix sockets, the native endpoint, SSE and gRPC all reject revoked tokens. - Revoke API.
POST /api/v1/converger/tokens/revoke: a token revokes its own family (logout); the channel secret revokes a given token's family or every token of an end user (ban). The subject's sockets are disconnected through the per-subject socket ids of ADR-0020. - Refresh rotation. Refresh inserts the
jtirow (kindrotated). The rotated token works forTOKEN_ROTATION_GRACE_SECONDS(30); afterwards it istoken_rotated(REST401 token_revoked). A refresh of a token rotated longer ago than the grace period revokes the family (token_reused). Only the refresh route (allow_rotated: trueonConvergerAuth) lets a rotated token through to detect this. - Permissions.
permsclaim, set withpermissionson generate. Enforced byConvergerWeb.Plugs.RequirePermissionon the REST controllers, inConvergerChannel(join needsread;postActivity/frameneedwrite;typingneedstyping) and inConvergerWeb.Protocol.Session(helloneedsread, starting a conversationwrite,textframeswrite,typingframestyping). New error codesinsufficient_permission(403, protocol 2004) andtoken_revoked(401, protocol 2005).
Consequences
Positive
- A revoked token is refused by every node within a second, and its sockets are closed; the two-node
:peersuite asserts it. - A stolen token used in parallel with the real client is detected at the next refresh, and both are cut off.
- Rotating
SECRET_KEY_BASEno longer disconnects WebSocket clients; client keys roll over without downtime. - Read-only viewer tokens (
["read"]) and send-only bots (["write"]) are possible.
Negative and trade-offs
- One small write per refresh and per revocation. The table holds about one row per refresh in the last 31 minutes.
- During the grace period a stolen copy of a just-refreshed token still works.
iathas a one-second resolution: a token generated for a user in the same second as that user's revocation is also rejected.- A node that starts while the database is unavailable accepts revoked tokens until its next sync (at most
sync_interval_ms). - Tokens without
jticannot be revoked by family; none are issued, and pre-#52 tokens all had ajti(Joken's default claims).
Follow-ups
- Admin and tenant portal hardening (2FA, password reset, policy, server-side sessions): the second part of #52, its own ADR.
- Server SDKs (Elixir, Go, Python) do not expose
permissionsandrevokeyet.
Implementation
- Keys:
Converger.Auth.Signer;TOKEN_SIGNING_KEYS,TOKEN_ACCEPT_UNKEYEDinconfig/runtime.exs. - Tokens:
Converger.Auth.ConvergerToken(derived_opts/1,refresh_claims/1,permitted?/2). - Revocations:
Converger.Auth.Revocations,Converger.Auth.RevocationCache, migration20261013100000_create_revoked_tokens.exs, pruning inConverger.Retention.prune/1. - API:
ConvergerWeb.ConvergerAPI.TokenController(revoke, rotatingrefresh),ConvergerWeb.Plugs.ConvergerAuth(allow_rotated),ConvergerWeb.Plugs.RequirePermission,ConvergerWeb.Sockets.disconnect_claims/1. - Tests:
test/converger/auth/converger_token_test.exs,test/converger_web/controllers/client_token_security_test.exs,test/converger_web/channels/converger_channel_permissions_test.exs,test/converger_web/protocol_token_security_test.exs, and the cross-node case intest/cluster/multi_node_test.exs.
Links
- Client tokens
- Client API: permissions, refresh, revoke
- Protocol v1 error codes
- Configuration: client tokens
- RFC 9700 (OAuth 2.0 Security Best Current Practice), refresh token rotation and reuse detection