Evolution of AuthN/AuthZ Architecture (1): From Monolith to Identity Provider
This is a 5-part series.
- Part 1 (this article): The problems monolithic session auth ran into, the fundamentals of OAuth/OIDC, and introducing an Identity Provider (IdP)
- Part 2: API Gateway and Microservices
- Part 3: Advanced Client Authentication and Incremental Migration
- Part 4: Internal Structure and Reliability Design of the Authorization Server
- Part 5: Data Model and Operational Design of a Home-grown Authorization Server
Why trace it as an "evolution"
There are countless articles explaining OAuth 2.0 and OIDC. But most of them stop at "explaining the flow," and I feel they leave out why that architecture became necessary — the context of the problem it was trying to solve.
Authentication and authorization architectures have evolved by being pulled along by changes in system structure (monolith → microservices) and organizational structure (single team → multiple teams). Put the other way around, if you misjudge which stage your own organization is at, you either build an over-engineered auth platform and drown in operational cost, or leave weak authentication in place and cause an incident.
In this series, I'll organize each architectural stage together with "the problem it solves," "adoption difficulty," and "organizational prerequisites." To borrow a legal analogy, it's a way of reading that follows not just the statutory text (the specification) but the legislative facts (why that law was needed). A specification can't be applied correctly without knowing its legislative facts.
Stage 0: Monolith + Session Cookie
Let's establish the starting point. Authentication in a traditional web application looked like this.
- The app itself holds the user DB and verifies passwords
- Authentication state is kept in a server-side session (memory / Redis / DB), referenced by the session ID in the cookie
- Authorization means "look up the user from the session, and judge that user's role in application code"
This is not a bad architecture. In fact, in a single-app, single-team world, I consider it close to optimal. The reasons:
- Sessions can be revoked immediately (logout = destroy it server-side, and you're done)
- Cookies are protected by the browser via
HttpOnly/Secure/SameSite - Because authentication and authorization live in the same process, consistency problems don't arise
The JWTs and tokens that appear at later stages solve other problems in exchange for partially giving up this "immediate revocation" and "browser protection." Not a single stage evolved without a trade-off — that's the recurring theme of this series.
The conditions under which Stage 0 breaks down
This architecture hits its limits when requirements like the following arise.
| Requirement that arises | Why session cookies struggle |
|---|---|
| More than one service (internal SSO) | Cookies are per-origin. You can't share auth state across services on different domains |
| Mobile app support | Cookie jar behavior differs from the web, making session management unstable. You also don't want the app to hold an ID/password long-term |
| Exposing an API to external partners | Your only option becomes "take the password and scrape" (the password anti-pattern discussed below) |
| Duplicated user DBs | User tables and password hashes proliferate per service, and leak risk and operational load grow linearly |
| MFA / passwordless support | Having each app implement WebAuthn or FIDO2 individually is impractical |
The Password Anti-Pattern — OAuth's legislative fact
If I had to name one direct trigger for OAuth's birth, it would be the "password anti-pattern." In the 2000s, when one service wanted to access another service's data, the approach taken was to have the user enter that service's ID/password, then log in on their behalf using the entrusted password (e.g., importing Gmail contacts into a social network).
What's wrong with this:
- You can only delegate full authority. You want to read only the contacts, but you hand over permission that can also delete mail. This is a complete abandonment of the principle of least privilege
- You can't revoke it. The only way to stop access is to change the password, which drags down all other integrations with it
- The third party holds the password in plaintext. If it leaks, the primary account gets taken over entirely
OAuth solved this by "handing over, instead of the password, a token that is scope-limited and individually revocable." In other words, OAuth is essentially not an authentication protocol but a delegation protocol. Keeping this origin in mind makes the "you must not authenticate with OAuth" problem discussed below easier to understand.
The cast and basic flow of OAuth 2.0
Let's sort out the terminology. OAuth 2.0 (RFC 6749) defines four roles.
| Role | Function | Example |
|---|---|---|
| Resource Owner | The owner of the resource = the user | You |
| Client | The app that wants to access the resource | A connected app, your own frontend |
| Authorization Server (AS) | The server that issues tokens | Keycloak, Auth0, Google |
| Resource Server (RS) | The API that provides the resource | Your own API, the Gmail API |
The central Authorization Code Flow looks like this.
The design point is that the paths are split into a front channel (the path via browser redirects, which is easily intercepted) and a back channel (direct server-to-server communication, protected by TLS). Only the short-lived, single-use "authorization code" is sent over the dangerous front channel, and the real prize — the access token — is exchanged only over the back channel. This two-tier structure is the heart of the authorization code flow, and PKCE and DPoP, covered in Part 3, add further layers of defense on the premise that "even so, the front channel is dangerous."
For details, see also OAuth2.
The "OAuth is not authentication" problem and OIDC
An OAuth access token only means "the Client may access the resource." Repurposing it as proof of "who the user is" becomes a vulnerability. A famous case is the token substitution attack: if a malicious app A feeds an access token legitimately obtained from the user into another app B's "OAuth login," B — seeing that the token is valid — mistakes the caller for the genuine user. This is because there was no standardized way to verify "who the token was issued for (audience)."
OpenID Connect (OIDC) is an authentication layer placed on top of OAuth 2.0 to plug this hole. Among the things it adds, the essential ones are:
- ID Token: A signed JWT. It contains
iss(issuer),sub(user identifier),aud(issued for this Client), andnonce(replay prevention). Because there is aud validation, it cannot be repurposed for another app - UserInfo endpoint and standard claims (name, email, etc.)
- Discovery (
/.well-known/openid-configuration): Makes the location of endpoints and public keys machine-readable, lowering integration cost
A note on aud. aud is a general-purpose claim representing the token's intended recipient (RFC 7519); for an ID token the recipient is the Client, so client_id goes in it (a MUST in OIDC Core). On the other hand, what goes in an access token's aud is the identifier of the Resource Server, not client_id (RFC 9068) — because the recipient is the RS. There's also a separate claim, azp (authorized party), which indicates "which Client obtained this token" when there are multiple audiences. "Whom the token is aimed at (aud)" and "who obtained the token (azp / client_id)" are different concepts, and this distinction also pays off in understanding Part 2's Token Exchange (exchanging tokens with the aud narrowed to the destination service).
As a practical mnemonic, I think it's good to remember: "an access token is an admission ticket to the API, and an ID token is an ID card presented to the Client." Sending an ID token to an API, and authenticating with an access token, are both misuses.
The trade-off in token format: JWT vs reference token
The contents of an access token are free per the specification, and there are broadly two approaches. This ties directly into the later API Gateway design (Part 2), so let's cover it up front.
| Self-contained (JWT) | By-reference (opaque) | |
|---|---|---|
| Verification method | Signature verification at the RS's end (public key fetched from JWKS) | Query the AS's introspection endpoint (RFC 7662) |
| Latency | No network round trip. Fast | An AS round trip every time (mitigated by caching) |
| Immediate revocation | Not possible. It stays alive until expiry | Possible. The AS reflects revocation instantly |
| Information disclosure | The payload is Base64 and readable by anyone. Put internal info in a token handed to the client and it leaks | The contents don't leak |
| Load on the AS | Low | High (all API calls concentrate on it) |
The "JWT can't be revoked immediately" constraint bites harder than you'd expect, becoming a problem when cutting off a departed employee's access or in an emergency response to a leak. In practice, the standard compromise is to "make access tokens short-lived (5–15 minutes) and make the revocation decision at the AS when the refresh token is renewed." That is, it's a design decision to tolerate revocation immediacy up to "a delay of at most 15 minutes," and for domains that can't tolerate that (e.g., executing financial transactions) you end up choosing by-reference or hybrid approaches (Part 2's Phantom Token pattern).
Stage 1: Introducing an Identity Provider (IdP)
Here begins the first architectural migration. You carve the authentication function out of the monolith (or a handful of apps) and stand up a dedicated auth platform — in OIDC terminology an OpenID Provider, generally an IdP (Identity Provider).
Each app throws away its own login screen and password table and replaces them with a redirect to the IdP. The app becomes an OIDC Relying Party (RP).
The problems it solves
- SSO: Because the IdP holds a session cookie on its own domain, if you're already logged in at App 1, the redirect to App 2 returns an authorization code in an instant. As a user experience, "log in once and get into all company services" is realized
- Centralized credential management: The storage location for password hashes becomes a single place. Leak monitoring, password policy, and account lockout are implemented once
- Centralized MFA/passwordless: Implement WebAuthn support in the IdP once, and it propagates to all apps. This was effectively impossible when implemented per app
- Centralized auditing: "Who logged in to which app and when" is all gathered in the IdP's logs. This pays off for SOC 2 and ISMS compliance
- Centralized on/offboarding (provisioning): Deactivating a departed employee's account is completed in one place. SCIM integration with the HR system also only needs to be supported by the IdP
The new problems it creates
Adoption isn't the end; new problems are born. Adoption projects that took this lightly have gone up in flames.
- Dual session management: The IdP's session and each app's session exist separately. The "logged out of the IdP but still logged in to the app" problem is guaranteed to occur. OIDC has Front-Channel / Back-Channel Logout specs, but because the implementation propagates to each app, it's surprisingly heavy
- Migrating the existing user DB: Password hash algorithms don't match so they can't be imported, duplicate email addresses, matching the same person across multiple apps — mundane, but most of the project's effort disappears here. In my experience, the technical hard part of IdP adoption is not the authentication protocol but data migration and handling exceptional users
- Becoming a single point of failure: If the IdP goes down, no one can log in to any service. Your availability requirements suddenly jump to the company's highest level
- Connecting legacy apps: For old apps that can't speak OIDC, you need a configuration where a reverse proxy takes over authentication on their behalf (a so-called auth proxy, like oauth2-proxy). This is also the prototype of Part 2's API Gateway
Build vs Buy
| Option | When it fits | Caveats |
|---|---|---|
| SaaS (Auth0, Okta, Entra ID) | The vast majority of companies where authentication isn't a core differentiator | MAU-based pricing bites at scale. Limits to the freedom of custom flows |
| OSS self-hosted (Keycloak) | Special requirements, data sovereignty requirements, cost optimization | You shoulder operations, upgrades, and availability yourself. Without a dedicated person, you'll have incidents |
| Home-grown | Almost never recommended | Authentication is the domain where the distance between "it works" and "it's safe" is greatest. Getting token issuance, revocation, and key rotation right is many times harder than you imagine |
I consider "don't build your own" almost an iron rule. The OAuth/OIDC specifications are a tangle of dozens of RFCs, and defenses against known attack patterns (mix-up attack, code injection, etc.) depend on implementation quality. It's less like reinventing the wheel and more like reinventing the brakes.
On the organizational side: The birth of the "auth platform team" and the beginning of governance
Stage 1 is a turning point not only for technology but for the organization. Since someone has to operate the IdP, someone has to be its owner, and this is where a structure emerges in which the platform team (or the IT/security team) holds the governance power over authentication.
On the governance side, this is a benefit (uniform policy application, audit readiness). On the other hand, from the perspective of each app team, dependency and bottlenecks are born: "we have to file for client registration every time we build a new app," "customizing the login screen depends on the platform team's priorities." This is the reverse effect of Conway's Law — the architecture begins to dictate the organization's communication structure — and this tension is sharpened further with the API Gateway and microservices in Part 2.
How far you push self-service (automating client registration, Dynamic Client Registration, managing with Terraform) is the first design decision that determines the balance between governance and autonomy.
The difficulty and decision criteria for Stage 0→1
- Technical difficulty: Medium. The protocol itself is mature, and building it is not hard if you use off-the-shelf products. The hard parts are data migration, legacy connectivity, and logout consistency
- Organizational difficulty: Medium to High. It becomes a cross-cutting project that involves all app teams. Without top-down driving force, it tends to stall half-finished as "IdP only for new apps, existing ones left to rot"
- Signals you should adopt it: You have two or more apps, you need mobile support, you've been asked for MFA, an audit flagged your password management — when any one of these appears, I think it's time to start considering it
Conversely, if you have a single app with no plans to change that for the foreseeable future, staying on session cookies is fine. There's no value in going OIDC for its own sake, and adopting it when there's no problem to solve is a liability.
In Part 2 (API Gateway and Microservices), we'll cover why, as the number of services grows, a configuration where "each service validates tokens on its own" breaks down, along with governance via an API Gateway and the patterns for token relaying and service-to-service authentication in microservices.
