Skip to content

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

MethodPathPurpose
GET/.well-known/oauth-protected-resourceResource metadata: resource <base>/mcp, authorization server <base>
GET/.well-known/oauth-authorization-serverServer metadata: endpoints, code, authorization_code, S256, scope mcp
POST/registerDynamic client registration (RFC 7591): redirect_uris, client_name → client_id
GET/authorizeStarts the flow and sends the user to the Rubin web app
POST/authorize/keygenWeb app only: the server creates the trading key and returns its public part
POST/authorize/completeWeb app only: the server checks the key on-chain and issues the code
POST/tokenauthorization_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
  1. Registration. The client registers itself (POST /register) and opens /authorize with a PKCE challenge.

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

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

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

  5. 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, version 1, no chainId; primary type RubinTransaction:ApproveAgent with rubinChain (the chain id), agentAddress, agentName, nonce and, 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.

  6. Verification. The web app calls POST /authorize/complete with 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's redirect_uri.

  7. Token. The agent exchanges the code with its PKCE verifier at POST /token and receives an opaque access token (rbt_…). From then on every MCP session sends it as Authorization: Bearer <token>.

What crosses the network

From → toData
Browser → MCP serverThe handoff; the account address
MCP server → browserThe key's public key and addresses; the redirect with the code
Wallet → Rubin ChainThe signed approval of the key's public address
MCP server → agentThe authorization code, then the access token
Private keyNowhere. 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

ItemLifetime
Handoff10 minutes
Authorization code2 minutes, single use
Access token30 days, extended while the connector is in use, never beyond the key's on-chain expiry
Trading keyChain 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:

  1. the service generates the key and keeps the private part;
  2. the user's wallet approves only the public address on-chain, with the scope the chain enforces;
  3. the service checks that registration on-chain before it starts acting;
  4. 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.