Making Coinbase multi-user

By Anant Jain, Eddie Salazar, Eric Ren, Mustufa Jan

TL;DR: How we adapted Coinbase's architecture, built for individuals, to support businesses.

Coinbase Logo

Bringing Coinbase to businesses meant supporting multiple people acting on the same account while reusing the services behind the consumer app.

There was a small problem, however — Coinbase, given its consumer roots, was a platform built for individuals where the user and the account owner are one and the same thing. Our services had a user ID but no separate account ID. Bringing businesses to Coinbase meant separating the user from the account across our retail stack.

Consider a BTC buy. When Bob buys for himself, the person placing the trade also owns the account. When he buys for his employer, Acme Inc., the company owns the funds, Bob needs permission to invest them, and the transaction history needs to identify him as the person who placed the trade. We needed to support business accounts across the existing retail stack without disrupting the paths that already served individuals.

Our first approach: Adding an account ID across the stack

The first obvious proposal was to introduce a separate account ID throughout the product. The existing user ID would identify the person acting; the new account ID would identify whose resources they could use. In isolation, that was a straightforward model, and that's how we would have built Coinbase if we were starting a business product from scratch.

Here is a simplified version of the code and storage migration we considered:

trade 1.1

Every service using user_id for ownership would need the new ID in its APIs, account lookups, stored trades, and downstream events. Existing records would need backfills. A BTC buy can cross several services, so migrating trading would require preparing the service and its upstream dependencies to understand account_id.

We ruled this out because enabling businesses would depend on a company-wide coordinated migration, with operational risk to paths that already served individuals. We needed a design that teams could adopt one journey at a time.

Our approach: Acting on behalf of an existing account

Expand what a user ID represents

We chose to expand what a user_id could represent: an individual’s account or a business account that multiple people could act on. When Bob buys BTC for Acme Inc., services use the user ID representing Acme Inc. to find its funds and record the trade. Bob’s own identity travels with the request for permission checks and attribution.

Today, the business is represented by the existing user ID of the person who opened its account, whom we call the primary user. Other members act on behalf of that identity.

diagram 1.1

Adapt identity at the service boundary

We next had to decide where to adapt the identity. Rewriting it globally at the gateway would also change the meaning of user_id for paths that had not yet opted into multi-user access. That would tie their rollout together, and as we mentioned earlier, being able to incrementally adopt this migration was a necessary requirement — we needed teams to prepare and enable journeys independently.

We put the adaptation in a shared Go gRPC interceptor inside participating services. This gave us a common point to adapt the handler’s ownership identity and apply each RPC’s permission and audit rules, while handlers kept their existing ownership lookups by user_id.

When Bob buys BTC for Acme Inc., the request follows four steps:

  1. Bob selects the business.

    He selects Acme Inc. in the frontend, and his requests carry that account selection in a header.

  2. The gateway validates his identity and membership.

    It establishes who Bob is and that he belongs to Acme Inc. The signed canonical authentication token (CAT) carries Bob’s identity and the business subject.

  3. The interceptor adapts the context and checks permission.

    It preserves Bob as the actor and supplies Acme Inc.’s primary-user ID in the handler’s request context. Before invoking the handler, it checks whether Bob’s role allows the requested action.

  4. The handler uses the business’s ownership identity.

    It finds Acme Inc.’s resources through its existing user_id lookups, which now use the primary user’s ID supplied by the interceptor in step 3.

diagram 1.2

Multi-User Posture Library: Make each RPC declare its rules

An interceptor could centralize the mechanics, but each customer journey’s owning team (for example, the Trading or Staking team) still had to decide what business users were allowed to do and what needed to be logged to the business's audit trail visible to admins. We chose protobuf annotations so those decisions would sit next to the RPC definition, where we could put in tooling in CI to validate explicit opt-ins or opt-outs.

We call this declaration an RPC’s Multi-User Posture. Permission and auditing are separate choices: an operation can require permission without producing an activity event, and business activity can require an audit event whether the actor is the primary user or another member.

For a simple fictitious example, let's assume Bob changes a simple setting for who can send payment requests to Acme Inc.: everyone, contacts, or nobody. Its annotation requires the business edit permission and asks the interceptor to audit the result:

code 1.2

The excerpts use our annotation syntax, with the business permission domain renamed for readability. Message definitions and unrelated API options are omitted.

An admin or owner has the edit permission. We split responsibility for building the audit event:

chart 1

This division lets us share permission and audit enforcement without teaching the multi-user-posture library the internals of every service.

Audit Trail Service: build a centralized audit trail for admins

We made the customer journey’s owning team choose the audit boundary because a successful RPC means different things in different journeys. A payment request setting update finishes in its handler. A BTC buy request can return while execution is still in progress across a durable workflow.

For an operation that finishes in the handler, we record an intent event before execution and an outcome event afterward. We chose different failure behavior on either side of the handler:

chart 2.2

The events share a correlation ID and remain separate records. The audit-trail-service retains intent and failed-outcome records, while the Activity tab within the dashboard lists successful outcomes.

diagram 1.33

For Bob's BTC buy, we made a different choice. CreateBuy creates a quote, and CommitTrade dispatches execution. Treating the latter's return as a completed purchase could incorrectly record a successful buy if the trade never executes. We kept the permission check at the RPC and assigned outcome reporting to the durable trading workflow, thereby opting the CommitTrade RPC out of the auditing at the service level.

cod1.4

The trade stores Bob’s identity, and workflow activities restore that context for downstream calls so Bob remains the actor after the request returns. The audit-trail-service consumes the trade’s lifecycle events alongside the interceptor events emitted for other RPCs.

We chose the customer-visible milestone for each flow. For an instant bank-funded BTC buy, the audit-trail-service records the buy when the transfer workflow reports that the customer has been credited. Crypto is available at that point even though the bank transfer has not settled.

Monitoring & Rollout

Before enabling delegation, service owners had to inspect uses of identity beyond account ownership: for example, personal security settings still belonged to the person who signed in, notifications still needed to be fanned out to individual users, and background work (e.g., through durable workflows) had to preserve the actor after the synchronous request returned.

1. Keep new RPCs from bypassing the contract

We made adoption enforceable at the service level:

code 1.5

With that option, every new RPC within the service must declare its permission and audit behavior, give reasons for opt-outs, or explicitly mark itself unavailable to business accounts. Adding a method cannot silently bypass the multi-user posture that the team put in place initially.

In our CI, the multi-user-posture linter rejects incomplete or contradictory declarations. Service startup validation checks the configuration the service actually loads, including dependencies and the code needed to build audit events.

2. Catch missing audit mappings in CI

An event can be stored correctly and still disappear when a customer filters their history. We put action-to-filter mappings in a shared registry. CI checks it against every action value in the protobuf schema: a new action needs a matching filter or an explicit exception, or CI fails. Integration tests exercise filtered reads through the API to catch mappings that would leave a stored event out of the results.

We also exercise shared state through the product itself. Our browser synthetic tests are configured to run at regular intervals in staging. For example, one test uses two members of the same business: an admin enables the business's withdrawal allowlist and adds an address. The test verifies that the write targets the business, that the admin's personal allowlist stays unchanged, and that a fresh sign-in as the primary user sees the same business entry. It then removes the test data and verifies cleanup.

3. Monitor identity and audit failures separately

We monitor identity-context failures, permission-service errors, and audit-event emission failures separately. Event delivery uses retries and dead-letter handling, while correlation IDs support investigation. We also monitor differences between intent and outcome events.

Learnings

Reusing user_id as the ownership key let us adapt business journeys at service boundaries without first replacing every ownership column and lookup. The shared interceptor brought this approach to trading, funding, transfers, staking, lending, and payments. This vastly simplified adoption of multi-user capabilities across our consumer product, enabling us to build Coinbase Business directly on top of our existing stack.

Recent stories

Disclaimers: Derivatives trading through the Coinbase Advanced platform is offered to eligible EEA customers by Coinbase Financial Services Europe Ltd. (CySEC License 374/19). In order to access derivatives, customers will need to pass through our standard assessment checks to determine their eligibility and suitability for this product.