How Coinbase built a governed platform for internal operations

By Brindal Patel, Teja Prakash Dadi

TL;DR: Coinbase’s internal teams used to rely on many separate admin tools, each with its own authorization, audit, and rate limiting. We replaced that sprawl with a single “Control Center” layer in front of domain services. It centralizes authorization, audit, approvals, and rate limits while letting product teams extend it safely via contracts and configuration, without turning the platform team into a bottleneck.

Coinbase Logo

A note on specifics: this post describes patterns and design decisions, not internal wiring. Service names, hostnames, schemas, and other implementation details are intentionally omitted so the ideas transfer to any team building similar tooling.

Any company that stores sensitive customer data eventually hits the same quiet problem. To get work done, whether that is helping a customer, investigating abuse, moving funds, or resolving a dispute, internal teams need tools that can read and write that data - support and operations, compliance, legal, risk, and the engineers building for all of them. Each has a legitimate but different reason to reach into customer data. That raises the question this post is about: when many teams need privileged access to sensitive user data, how do you avoid a sprawl of one-off tools while preserving speed and governance?

Left alone, the tools that serve those teams multiply. Each new one widens the surface area that a security, compliance, or platform team has to keep track of. And no single place can answer the question that matters most:

Who can do what, to whose data, and how do we know it happened?

For one tool, that is an easy question. For thirty, it is close to impossible. The usual response is to write a governance doc and trust that every team follows it, but consistency that depends on people remembering tends to fall apart the moment nobody is watching.

We took a different path. Instead of building admin tools as independent apps, we built a centralized platform. This post covers why we built it, the shape it took, the tradeoffs we made, and what we would tell another team taking on the same problem. Later posts in the series cover how we are adding AI on top of it, and where we take it next.

Design Requirements

Before writing any code, it helped to write down the guarantees we wanted. For a system that sits in front of sensitive operations, the list came out to six:

  1. Uniform authorization. One model for "can this actor perform this action on this resource," applied the same way everywhere.

  2. Least privilege by default. Nobody holds standing access to data they are not actively working on.

  3. Complete, tamper-evident audit. Every sensitive read and write is recorded because the system records it, not because a developer remembered to.

  4. Safe mutations. High-impact changes take more than one person.

  5. Consistency across many backends. The same customer and the same rules, no matter which backend the data lives in.

  6. Self-service extensibility. Product teams can extend the platform with their own tooling directly, with the platform team owning the guardrails rather than reviewing every feature.

That last one is where the difficulty lives. Centralize too little and you are back to sprawl. Centralize too much and the platform team becomes the thing every other team waits on. Most of the interesting design work went into getting the guarantees and the speed at the same time.

Architecture Overview

We didn't want a monolith that owned all the domain logic, and we didn't want a loose collection of independent tools either. What we landed on is an umbrella service: a single entry point in front of many domain-specific backends, enforcing policy on the way through.

The umbrella handles the cross-cutting concerns once - identity propagation, authorization, rate limiting, audit, and approvals - while domain logic stays in the backends where it belongs. The mental model is a deliberate inversion:

  • Distribute capability. Any backend team can expose an operation through the umbrella.

  • Centralize control. Every one of those operations automatically inherits the same authorization, audit, and rate-limiting path.

eaa

Lesson: put the properties that must never regress (authorization, audit, rate limiting) in one shared layer, and let domain logic stay where it already lives. Centralize control, distribute capability.

Design Tradeoffs

No honest architecture write-up is only a list of wins. A few of these were deliberate compromises.

  • Authorization fails closed; rate limiting is only a throttle. Authorization is the primary defense, so it never trades safety for uptime: it denies whenever it cannot reach a confident decision. That single guarantee is what makes it safe for every other control, rate limiting included, because none of them is the last line between a caller and data it should not see. Leaning on authorization this heavily has a cost, though: the fail-closed boundary has to be re-validated every time a new class of caller is added, rather than assumed to still hold. We treat "who can reach this, and how they prove it" as a check to re-run whenever the set of callers changes.

  • One central platform is a hop and a shared failure domain. Routing everything through one service adds latency and concentrates risk. We accepted that for consistency we could not get any other way, then invested heavily in that path's reliability.

  • Config-driven behavior cuts both ways. Moving behavior into configuration makes the platform fast to extend, and it also turns configuration into something you have to review, version, and guard as carefully as code.

Request Lifecycle and the Ordered Check Chain

The single most important structural decision is that cross-cutting concerns live in an ordered chain that every request passes through, and never inside individual handlers. A request travels through roughly these stages:

  1. Error normalization (outermost). Inspects the outcome, tags observability spans, and classifies the result.

  2. Audit capture. Records the declared audit fields for the call and forwards them to the audit pipeline.

  3. Authorization. Resolves the caller's identity to a set of permissions and enforces the endpoint's requirement, including per-resource checks.

  4. Rate limiting. Applies whichever limit is relevant.

  5. Identity injection. Makes the authenticated caller available to the handler.

Because every request goes through this chain, a handler author cannot forget to authorize or audit. Those properties are structural, not something you opt into. Adding a new endpoint means declaring what it needs, and the enforcement comes for free.

eaa 2

Lesson: make cross-cutting guarantees structural. If authorization and audit are steps every request must pass through, no one can forget them.

Error Handling

Here is a decision that looks like plumbing but shaped the whole stack: our handlers return business errors inside the response payload, not as transport-level errors. That choice ripples outward. It changes what the API gateway can translate cleanly (and so what users see on a failure), and it means tracing and monitoring — which key off transport status codes — miss payload-level errors unless you deliberately surface them for your dashboards and SLOs.

Lesson: decide early whether your errors are transport events or payload data. That one choice propagates all the way to your gateways, dashboards, and users.

Resource-Level Authorization

Most permission systems answer a single question: can this user call this endpoint? That is necessary but not enough. When an endpoint acts on a specific customer, what you actually care about is whether the user can act on that customer. Our authorization model stacks three checks so it can answer both at once.

  • Endpoint to permission. Every sensitive endpoint declares the named permission it requires. Membership is group-based and administered outside the codebase.

  • Permission decoupled from role. We enforce on the capability, not the job title — capabilities are stable, org charts churn. Roles remain a useful administrative grouping, but they aren't what the check evaluates.

Per-resource scoping. When an endpoint carries a customer identifier, authorization is evaluated against that customer, not the action alone. No identifier on a customer-scoped endpoint -> it fails closed. The missing context is "no," never "yes."

eaa 3

Lesson: authorize the action and the specific resource, treat missing context as denial, and bind permissions to capabilities rather than job titles.

Just-in-Time, Least-Privilege Access

An operator who can reach every customer is a permanent risk, even if they only ever touch a handful.

So we tied access to the work itself. When someone is assigned a case, an event-driven pipeline automatically grants them time-boxed access to exactly the customers that case involves, and that access expires on its own. Nobody files a ticket to grant it and nobody has to remember to revoke it: access is created by the work and cleaned up by the clock. That turned out to be the highest-leverage control in the system, because it changes "who can see this customer?" from an ever-growing access list into a small, self-cleaning set that maps to real, current work.

Lesson: tie access to active work and let it expire. Standing access is the liability; work-scoped, self-expiring access is the fix.

Declarative Audit Logging

Audit logging that depends on a developer remembering to add it is audit logging with holes, and you find the holes during an incident, which is the worst possible time to find them.

So we made it declarative and pushed it into shared middleware. Each endpoint declares the fields that make up its audit record, and a shared audit step in the check chain records exactly those fields on every call before forwarding them to the audit pipeline. Because the declaration is part of wiring the endpoint in, an endpoint without an audit contract is simply an endpoint that is not finished. The result is a trail of who accessed which customer's data, and when, that is complete by design rather than by good intentions.

Lesson: make completeness structural. If declaring the audit record is part of shipping an endpoint, you cannot ship one with a gap.

Multi-Party Approval for Sensitive Mutations

Some actions - issuing a refund, changing an account's state, overriding a limit - should not come down to one person acting alone. Those go through an asynchronous, two-phase approval flow.

The first call does not change anything. It opens a review that packages up the intended change, a description of the before and after state plus a reference to the resource being touched, and hands it to an approval service. Only once the required approvals are in does a separate, queue-driven executor carry out the real mutation. Failures are set aside for a human rather than lost. 

Splitting "propose" from "commit" this way buys three things at once: a natural enforcement point for multi-party approval, a retry-safe (idempotent) execution step, and an audit record covering both the request and the action. And because new sensitive actions join through a generic change contract, adding approvals to an operation means describing the change, not rebuilding the workflow.

Lesson: separate proposing a change from committing it. One pattern gives you multi-party approval, an idempotent retry boundary, and a clean audit trail.

Rate Limiting

We run two independent rate limiters because they defend against different things. The first caps how many distinct customers a single caller can touch in a window, which blunts broad scraping. The second guards endpoints that have no customer scope - like a lookup by a sensitive identifier - where an unbounded endpoint would be an enumeration risk.

Rate limiting is a throttle, not the security boundary. The security guarantee lives in authorization, which is always fail-closed, so no request bypasses an access decision regardless of how the limiters behave. That separation lets the limiters be tuned for legitimate operator throughput without ever widening what a caller is allowed to see.

Lesson: layer your defenses and be explicit about which layer is the security boundary. Keep the primary gate fail-closed.

Contract-Driven API Surfaces

Client applications don't talk to Control Center directly - they go through a gateway that handles authentication and proxying. The piece worth borrowing: both the external and internal API surfaces are generated from the same contract that defines the service. One source of truth means the surfaces can't drift from the service beneath them, and a contract change ships with its implementation in a single review instead of being coordinated across two systems.

Lesson: generate every surface from one contract. Surfaces derived from the same source can't drift, and every change stays reviewable in one place.

Extensibility Through Configuration

The classic way a central platform dies is by becoming the team everyone waits on. We pushed hard against that by keeping the things that change often in configuration rather than code. New access roles and their group mappings, approval tiers and thresholds, rate-limit rules, turning a downstream integration on or off, even new AI-generated summaries: all of it is runtime configuration, not a deploy.

For the things that genuinely need code, there is one recipe: define the contract, implement a handler, register a permission, and declare an audit entry. It is the same shape every time, and the shared check chain supplies the rest. A product team can extend the platform largely on its own, and the platform team owns the guardrails instead of every feature. That is how you keep a central system from turning into a queue.

None of this maintains itself. Every new class of client, a new UI, a batch job, or an automated agent, is one more entry point that has to be pulled under the same authorization, audit, and rate-limiting rules. Governance here is a continuous operational investment, not a one-time architectural choice. The guardrails hold only as long as keeping each new surface inside them stays part of the routine work of running the platform, rather than something rediscovered during an incident.

Lesson: make the common extension a config change and the rare one a single fixed recipe. That is how a central platform adds capability without becoming a bottleneck.

Conclusion

A single, consistent, auditable Control Center is not only a compliance and security win. It is the shared surface that customer support, compliance, legal, risk, and engineering all use to act on customer data under one set of rules. It also happens to be the foundation for automation. Once every operation is a permissioned, audited, approvable action behind one interface, you have exactly the substrate an AI or automated agent needs to act safely, because the agent inherits the same guardrails a human does. The one thing that boundary does not hand you for free is authentication itself. However a new client proves who it is, the token or identity it presents is the highest-leverage point in the whole design, because every control downstream is only as trustworthy as the actor it believes it is acting for. Bringing a new class of client inside these rules, an automated agent included, is mostly a matter of getting that one boundary right.

If you are building something in this shape, the ideas that carried over best were:

  • Put enforcement in one ordered chain that every request has to cross, and do not rely on handlers to remember.

  • Authorize per resource and fail closed. Treat missing context as a denial.

  • Separate proposing a change from committing it for anything sensitive.

  • Tie access to work and let it expire, instead of managing standing access lists.

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.