The Extensibility Trap: Why Every Identity Platform Needs Opinions

Every enterprise deal eventually produces the same sentence, usually about forty minutes into a technical call, usually phrased apologetically:

Every enterprise deal eventually produces the same sentence, usually about forty minutes into a technical call, usually phrased apologetically:

"Can I just..."

Run a small Java class during login. Call our ERP to enrich the token. Invoke a REST endpoint. Write a bit of Groovy. Upload a JAR. Drop in some JavaScript.

Each request is reasonable. Each has a genuine business need behind it. Each is small. And saying yes to all of them, one deal at a time, is how identity platforms turn into middleware.

The progression nobody notices while it's happening

It's never one decision. It's six, spread across three years, each of which looked obviously correct at the time.

v1. Customers need custom attribute mapping — map an LDAP field into a SAML attribute. Pure configuration. Ship it. Everyone's happy.

v2. A customer needs an employee number that isn't in the directory. Configuration can't express "go get this from somewhere else," so you add a small callback hook. It's constrained. It's fine.

v3. Another customer needs manager hierarchy for their approval workflows. The hook now does an LDAP query at login.

v4. SAP integration. The hook makes a REST call.

v5. Oracle HR. SOAP, this time.

v6. A mainframe. Custom JAR, because nothing else can speak to it.

Now look at what a login does:

flowchart LR
    L["Login"] --> DB[("Identity DB")]
    DB --> LDAP["LDAP"]
    LDAP --> REST["SAP REST"]
    REST --> SOAP["Oracle SOAP"]
    SOAP --> MF["Mainframe JAR"]
    MF --> T["Token issued"]

Nobody designed that. It accreted. And the identity platform is now an orchestration layer for five systems it doesn't own, on the critical path of every authentication in the organization.

The production system that taught me this

The strangest one I've encountered fetched an employee ID by making a synchronous call to a legacy HR system during every SAML login, purely to populate one attribute in the assertion.

It worked fine for a long time.

Then the HR system got slow. Not down — slow. Overnight, authentication latency roughly doubled, SSO became unreliable, and the identity platform started failing for reasons that had nothing whatsoever to do with identity.

The authentication service hadn't gotten slower. It had become dependent on someone else's availability.

That's the sentence worth keeping, because it generalizes: every millisecond spent waiting for another system is another millisecond your users cannot authenticate. And every nine of availability that system lacks is a nine you no longer have, regardless of how carefully you engineered your own.

The people who owned that HR system had no idea they were in the authentication path. Why would they? Nobody told them their batch-oriented, business-hours-SLA system had become a hard dependency of every login in the company. They ran a maintenance window on a Saturday, exactly as their runbook allowed, and took down SSO.

What extensibility actually costs

The problem isn't aesthetic. It's that identity systems are supposed to have a specific set of properties, and injected code destroys all of them.

Deterministic. Same inputs, same outputs, same time. A plugin making a network call is nondeterministic by construction. It's fast until the third-party system has a bad afternoon.

Predictable latency. You can budget a login: a hash, a session write, a signature. You cannot budget "and then whatever the customer's code does."

Highly available. Your availability is now the product of every dependency your customers' extensions reach. Five dependencies at 99.9% each, and the authentication path is at 99.5% — worse than any component in it, and worse than anything you promised.

Isolated. In a multi-tenant platform, one tenant's plugin exhausting a thread pool or leaking memory degrades every other tenant on that instance. The blast radius of a customer's code is not the customer.

Those four properties are most of what an identity platform sells. Extension points trade them away in exchange for closing individual deals.

Benchmarks stop meaning anything

Suppose you've done the work and can honestly say the platform sustains 1,000 authentications per second.

A customer deploys a plugin containing, in effect, Thread.sleep(3000) — not maliciously, just a synchronous call to a system that's having a bad day.

What's your throughput now? Whatever their dependency allows. Your benchmark describes a system that no longer exists in their deployment.

And when they open a performance ticket, the question "whose fault is this?" has no clean answer. You can't prove it's their code without instrumenting their code. They can't prove it isn't. The conversation goes in circles for three weeks because both parties are reasoning about different systems while using the same product name.

This is the support pattern that makes extensibility genuinely expensive over time. The customer reports slow authentication. You can't reproduce it, because your environment doesn't have their plugin, their network, or their mainframe. Every diagnosis requires access to a system you don't own and can't instrument. Tickets that would take an hour take a month.

Every extension point is a permanent public API

This is the consequence teams underestimate most, and it's the one with no escape hatch.

The moment a customer's code compiles against your plugin interface, that interface is frozen. Not "deprecated with a migration path" frozen — actually frozen, because the customer's plugin was written by a contractor in 2021 who no longer works there, against an internal HR system nobody wants to touch, and there is no engineering capacity to rewrite it.

So version 2.0 ships with a changed plugin API, and now:

  • The customer can't upgrade, so they sit on an old version and eventually on an unsupported one.
  • You can't refactor the internals the interface exposes, because that's a breaking change for every customer with a plugin.
  • Your architecture gets pinned in place by the shape of an API you designed early, when you understood the problem least.

Hyrum's Law applies with full force: with enough users, every observable behavior of your interface becomes something someone depends on. Not just the documented contract — the ordering, the timing, the exact exception types, the fact that a certain field happens to be populated. You will discover these dependencies by breaking them.

An extension point is not a feature you ship. It's a maintenance obligation you accept indefinitely.

The security argument is the sharpest one

Where does that plugin code execute? Inside the identity runtime. The same process that holds signing keys in memory, session state, tokens in flight, and every tenant's data.

You have accepted arbitrary code into the most security-sensitive process in the infrastructure. One bug — not even a malicious one, just a bad one — and the compromise isn't scoped to the customer who wrote it.

Sandboxing helps and doesn't solve it. You can cap CPU, limit memory, enforce timeouts, restrict the classes that load. What you fundamentally cannot do is make a network call's tail latency bounded, or make a third-party dependency available. And every layer of sandboxing you add is a layer you now have to maintain, secure, and explain — while still not having recovered determinism.

There's also a failure-mode question that's genuinely nasty and rarely thought through in advance: when a customer's extension fails, does login proceed or fail?

Fail closed, and a broken plugin — or a maintenance window on their HR system — locks every user out. Fail open, and you're issuing tokens with missing or stale attributes, which downstream applications will use for authorization decisions. If that attribute was a department code driving access control, failing open is a silent privilege bug.

Both answers are wrong. That's the tell: the architecture has put you in a position where there's no correct behavior available.

What to offer instead

The answer isn't rigidity. Customers have real needs, and "no" without an alternative loses deals for good reasons. The answer is to give them expressive power without arbitrary execution.

Declarative claim and attribute mapping. Most requests are genuinely just transformation: rename, concatenate, conditionally include, derive one value from another. A constrained expression language covers a surprising share of what people ask for while remaining bounded, analyzable, and fast.

A real policy engine. Conditional logic, group and role evaluation, rules keyed on request context. Expressive, terminating, no I/O.

Async webhooks and events, off the critical path. If a customer needs their CRM notified on login, publish an event. Their system consumes it on its own schedule, at its own reliability. Nothing about the authentication waits.

And the one that actually solves the hard cases: pre-compute instead of fetching.

This is the architectural move most of these requests should resolve to. When a customer says "I need the employee's cost center from SAP in the token," the instinct is to fetch it during login. The right answer is almost always to synchronize it beforehand — via SCIM, a scheduled sync, or a push from the source system — so that at login time it's already a local attribute.

flowchart TB
    subgraph Bad["Fetch at login"]
        L1["Login"] --> HR1["HR system\n(synchronous)"] --> T1["Token"]
    end
    subgraph Good["Sync ahead of time"]
        HR2["HR system"] -->|"SCIM / scheduled sync"| Store["Identity store"]
        L2["Login"] --> Store --> T2["Token"]
    end

Same data in the token. Completely different failure characteristics. The HR system going down now means attributes are stale, not that nobody can log in. Latency is a local read. The dependency has moved off the critical path without anyone losing a capability.

The reason this isn't the default answer is that it requires the customer to do integration work in their own infrastructure, and the plugin feels easier. It is easier — right up until the Saturday maintenance window.

The "while you're there" problem

There's a gravitational pull worth naming explicitly, because it explains why this keeps happening across every vendor.

The identity platform sees every user, on every login. So it becomes tempting to do things there. Since you're already authenticating them... can you also fetch their HR data? Calculate their roles from four systems? Call the CRM? Log it to the warehouse? Run it past a model?

Each addition is marginal — you're already in the request, what's one more call? But identity's position as the universal chokepoint is exactly why it must stay narrow. A component that everything depends on can afford the fewest dependencies of its own. Inverting that is how you end up with a system where a mainframe's availability determines whether anyone in the company can read their email.

The domain boundary is worth stating plainly. An identity platform answers who are you, and what may you access? It does not answer what is this person's current cost center according to SAP? The second question is legitimate and important — it just belongs to a system that isn't in the authentication path.

The hard part is what you leave out

Good identity platforms are intentionally boring. They authenticate users, issue tokens, evaluate policies, and get out of the way. The more business logic they accumulate, the harder they become to scale, secure, benchmark, and evolve.

Every "can I just..." request is a small trade: a bit of the platform's determinism, availability, or upgradeability, in exchange for solving one customer's problem today. Individually, every one of those trades is defensible. Collectively, they're how a product loses the properties that made it worth buying.

Which is why platforms need opinions — and why the answer to a reasonable request is often "not that way, but here's how." That's a harder conversation than shipping a plugin API, and it's the one that determines whether the product is still good in five years.

The hardest part of building an identity platform isn't deciding what to put in. It's deciding what to leave out.


Disclosure: I work on ClavionX, where we decided not to support customer code execution inside the identity runtime — the reasoning above is why. It's a real constraint with real costs; there are requests we can't satisfy as directly as a plugin API would. I've tried to argue the principle on its merits rather than the product, because the trade-off applies just as much if you're running Keycloak, Auth0, or something you built yourself.