OAuth & Trading Keys
The Rubin MCP server is an OAuth 2.1 authorization server for its own resource: authorization code with PKCE (S256), dynamic client registration and public clients without a secret. MCP clients find all of it from the discovery documents, so connecting is "add the URL, sign once".
What makes the flow different from a plain OAuth login is how the trading key is issued: the MCP server generates the key itself, your wallet approves only its public address on-chain, and the private key never crosses the network — not to the browser, not to the agent, not into the model context.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /.well-known/oauth-protected-resource | Resource metadata: resource <base>/mcp, authorization server <base> |
| GET | /.well-known/oauth-authorization-server | Server metadata: endpoints, code, authorization_code, S256, scope mcp |
| POST | /register | Dynamic client registration (RFC 7591): redirect_uris, client_name → client_id |
| GET | /authorize | Starts the flow and sends the user to the Rubin web app |
| POST | /authorize/keygen | Web app only: the server creates the trading key and returns its public part |
| POST | /authorize/complete | Web app only: the server checks the key on-chain and issues the code |
| POST | /token | authorization_code + code_verifier → access token |
<base> is https://mcp.mainnet.rubin.trade or https://mcp.testnet.rubin.trade. A request to /mcp without a valid token gets 401 with WWW-Authenticate: Bearer resource_metadata="<base>/.well-known/oauth-protected-resource" — that is where a client starts.
The flow
Agent host MCP server Web app (rubin.trade) Rubin Chain
│ POST /mcp ───────────►│
│ ◄─── 401 + resource_metadata
│ discovery, POST /register ─►│ client_id
│ GET /authorize (PKCE) ─────►│ handoff ──► 302 /oauth/authorize?handoff=…
│ │ │ user picks the account
│ │◄── POST /authorize/keygen
│ │ generates the key, stores it encrypted
│ │──► public key + addresses
│ │ │ wallet signs ApproveAgent(address) ──►│ registers the key
│ │◄── POST /authorize/complete (handoff, account)
│ │ checks the registration ─────────────────────────────────►│
│ ◄──────────── redirect ?code=…&state=…
│ POST /token (code, code_verifier) ─►│
│ ◄─── access token (opaque, 30 days)
│ POST /mcp, Authorization: Bearer <token> ─►│ session acts with the trading key-
Registration. The client registers itself (
POST /register) and opens/authorizewith a PKCE challenge. -
Handoff. The server remembers the request as a one-time handoff (valid 10 minutes) and redirects the browser to the web app's authorize page.
-
Choosing the account. The user sees the current account (or connects another wallet) and continues. The page says plainly that a new API trading key is issued for this account.
-
Key generation on the server. The web app calls
POST /authorize/keygen. The server generates a fresh secp256k1 key, encrypts it and keeps it; the response carries only the public key and the key's addresses. -
Approval on-chain. The user's wallet registers that address as a trading key of the account:
- EVM wallets sign EIP-712 typed data — domain
RubinSignTransaction, version1, nochainId; primary typeRubinTransaction:ApproveAgentwithrubinChain(the chain id),agentAddress,agentName,nonceand, when set,expiresAt; - other wallets sign a transaction that adds an authenticator with the key's public key.
For EVM wallets the approval transaction carries no fee.
- EVM wallets sign EIP-712 typed data — domain
-
Verification. The web app calls
POST /authorize/completewith the handoff and the account address. The server reads the account's authenticators from the chain and continues only if the key it generated is registered there. Then it issues a one-time code (valid 2 minutes) and returns the redirect to the agent'sredirect_uri. -
Token. The agent exchanges the code with its PKCE verifier at
POST /tokenand receives an opaque access token (rbt_…). From then on every MCP session sends it asAuthorization: Bearer <token>.
What crosses the network
| From → to | Data |
|---|---|
| Browser → MCP server | The handoff; the account address |
| MCP server → browser | The key's public key and addresses; the redirect with the code |
| Wallet → Rubin Chain | The signed approval of the key's public address |
| MCP server → agent | The authorization code, then the access token |
| Private key | Nowhere. It is generated on the MCP server and stays there |
How the key is stored
The server keeps no secret of its own that could unlock the stored keys — the token is the key:
- the database holds only
sha256(token)to find the grant; - the credential with the trading key is encrypted with AES-256-GCM under a key derived as
HKDF-SHA256(token, salt = grant id, info = "mcp-oauth-credential-v1"); - while the flow is in progress, the key is encrypted the same way under the handoff, then under the one-time code, and finally under the token; each intermediate record is deleted when used.
A copy of the database therefore contains hashes, ciphertexts and public metadata (account and key addresses, client name), but no tokens and no private keys. A decrypted credential exists only in the memory of the running server for the active session.
Lifetimes
| Item | Lifetime |
|---|---|
| Handoff | 10 minutes |
| Authorization code | 2 minutes, single use |
| Access token | 30 days, extended while the connector is in use, never beyond the key's on-chain expiry |
| Trading key | Chain default for keys approved without an explicit expiry — currently 180 days |
There are no refresh tokens. When the token or the key expires, the client asks you to authorize again — one more signature.
Reusing the pattern
The same scheme fits any service that has to act for a user without ever holding a key the user handed over:
- the service generates the key and keeps the private part;
- the user's wallet approves only the public address on-chain, with the scope the chain enforces;
- the service checks that registration on-chain before it starts acting;
- the private part is encrypted under a key derived from the token the client holds, so the service's storage alone is useless.
The user never copies a key, nothing secret appears in a URL or a page, and revoking is one on-chain action — see Permissions.