I was once handed a requirement that, on paper, meant modifying 45 applications. I shipped it by modifying none of them.
The setup
The estate was 45 ColdFusion applications. Not new ones: the kind of apps that had been quietly doing their job for years, built on a platform the rest of the industry had long since moved past. They worked. Nobody wanted to reopen them.
None of them handled their own login, either. They all ran behind Windows Integrated Authentication, so signing in was something the server did for them, not something any of them had code for.
Then the mandate came down: Windows Authentication had to be turned off, and everything had to move to a federated identity provider. First ADFS, speaking OIDC. Not one of those 45 applications spoke OIDC.
The obvious path was also the worst one: go into all 45 codebases, bolt an OIDC client into each, test each, and maintain 45 copies of essentially the same authentication logic forever. That is 45 chances to get it wrong, 45 things to patch the next time a protocol detail changes, and 45 legacy apps cracked open for a reason that has nothing to do with what they actually do.
I did not want to touch any of them. So I looked for a place where I would only have to solve the problem once.
The reframe
Here is the shift that made the whole thing tractable: authentication is not really the application's job. It is a cross-cutting concern. Every one of those 45 apps needed the exact same thing, which is to know that the person is who they say they are before letting them in. That is not 45 different problems. It is one problem, repeated 45 times.
And there was already a place in the stack that saw every request to every app before ColdFusion ever did. This is where I got lucky: these apps ran on ColdFusion on IIS, on Windows. Every request to any of them passed through the IIS pipeline first, and IIS lets you drop in a native module that runs inside that pipeline, on every request, for every site on the box. That is a clean, server-level seam I controlled, sitting in front of all 45 apps at once.
I want to be honest that this was circumstance as much as insight. If those apps had been on Apache or nginx, or spread across Linux boxes, the idea would still hold, but the single convenient place to stand would have looked different, and the work to get there might not have been so tidy. The precondition that made this cheap was not "ColdFusion." It was "everything sits behind one extensibility point I actually control."
Given that I had one, the question stopped being "how do I add OIDC to 45 apps?" and became "how do I add OIDC to the one thing all 45 apps sit behind?"
Where the module hooks in
An IIS module is just a class that implements IHttpModule. IIS hands it every request and lets it subscribe to stages of the pipeline. The one that matters here is the authentication stage. It fires early, before the request is ever routed to the application, which is exactly where you want to make an "are you allowed in?" decision.
The skeleton is small. You register for the event in Init, and do your work in the handler:
public class OidcAuthModule : IHttpModule
{
public void Init(HttpApplication context)
{
// Fires on every request, for every app on this server,
// before the request reaches ColdFusion.
context.AuthenticateRequest += OnAuthenticateRequest;
}
private void OnAuthenticateRequest(object sender, EventArgs e)
{
var ctx = ((HttpApplication)sender).Context;
// Already have a valid session? Let it through untouched.
if (HasValidSession(ctx))
return;
// This is the IdP redirecting back with an auth code.
if (IsAuthCallback(ctx.Request))
{
CompleteSignIn(ctx); // validate, then establish the session
return;
}
// Otherwise: no session, not a callback, so start the OIDC handshake.
BeginSignIn(ctx); // redirect to the identity provider
}
public void Dispose() { }
}
That is the entire control flow of the front door, and it is worth sitting with how little there is to it. Every request lands in one of three states, and the module routes it:
- Valid session already: do nothing, get out of the way, let ColdFusion serve the page.
- Coming back from the IdP with an auth code: finish the handshake, create the session.
- Everything else: kick off the OIDC flow by redirecting to the identity provider. The OIDC handshake itself (building the authorization request, handling the redirect back, exchanging the code for tokens, and validating them) is standard, well-trodden work in modern C#. You lean on a mature library for it rather than hand-rolling token validation, because that is the part you really do not want to get subtly wrong. The important architectural point is not the handshake. It is where the handshake runs. It runs here, once, in a single module, not scattered across 45 applications, and not in ColdFusion, which never has to know any of this happened.
By the time a request clears this module, one thing is guaranteed: whoever is behind it has been authenticated against the identity provider. Which raises the next question. How do the 45 apps actually know who the user is? That is where it gets interesting, and where you have to be careful.
The part that meant I touched nothing
Here is the detail that turned "modify 45 apps" into "modify zero apps," and I want to be clear that part of it was the situation handing me a gift.
None of those 45 applications ran their own login. They never had. Under Windows Integrated Authentication, IIS authenticated the user before the request reached ColdFusion and handed the app the authenticated username in a server value the app simply read. A typical app did nothing more than this:
<cfset authenticatedUser = CGI.AUTH_USER>
That line was already there. The apps had always trusted whoever the server said was at the door. They did not care how that identity was established. They only cared that it was.
This reframes the mandate in a useful way. "Turn Windows Authentication off and go federated" sounds like it is about adding OIDC. What it actually threatens is the identity value every one of those apps depends on. Kill Windows Auth and CGI.AUTH_USER goes empty across all 45 apps at once. They do not break because they lost OIDC. They break because they lost the one thing they were quietly relying on.
So I did not need the apps to learn OIDC. I needed to keep that identity value populated after Windows Auth was gone, and populate it from the new source instead. The module does the OIDC handshake, and when it is done, it sets the very same authenticated-user value the apps had always read, now sourced from the identity provider rather than from Windows. From ColdFusion's side, nothing changed. It still reads the logged-in user from the same place it always had. Behind that place, the engine had been swapped out.
That is why I touched no application code. Not one line. The apps were already built to trust a server-provided identity, so moving them to federated login was a change at the boundary, not in the code.
The one rule that keeps this safe
Windows Auth gave those apps a guarantee for free: the identity value could be trusted because the server owned it, and a user could not simply declare who they were. The moment you take over populating that value yourself, that guarantee becomes your job.
A value you put on the request, whether it is a server variable or a header, is only as trustworthy as your discipline about who else can set it. The example below uses a header to make the rule concrete. Nothing stops a user from opening dev tools or curl and sending their own copy:
GET /somepage.cfm HTTP/1.1
X-Authenticated-User: admin
If anything downstream reads an attacker-supplied value and believes it, you have not built authentication. You have built a system where anyone can be anyone by typing a name, which is worse than no SSO at all.
So the module enforces one rule on every request, without exception: it owns the identity channel, and nobody else writes to it. Before it sets the authenticated user, it strips any copy that arrived from the client. An inbound value never survives to reach the app behind it.
private void StampIdentity(HttpContext ctx, string username)
{
var headers = ctx.Request.Headers;
// Defense in depth: scrub any client-supplied copy FIRST, on every
// request, whether or not we are about to set our own. A value from
// the wire must never reach the app behind us.
headers.Remove("X-Authenticated-User");
// Only the module, on a request it has actually authenticated,
// gets to set this.
headers["X-Authenticated-User"] = username;
}
Strip first, then set. That ordering is the whole game. The apps can trust the identity they read precisely because the only code path that can populate it is the module, and only after a real sign-in. Whatever a client tries to inject is gone before the request moves on.
The payoff: when the requirement changed
The real test of an architecture is not how it handles the problem you built it for. It is what happens when the problem changes.
It changed. Some time after ADFS was in place, the mandate moved again, this time to DUO. A new identity provider, a new set of endpoints, new configuration for the handshake.
In the world where I had bolted OIDC into all 45 applications, that news lands like a second project: 45 apps to revisit, re-test, re-deploy, and re-certify, each one its own little chance to break. The cost of the original shortcut comes due all over again.
In the world I actually built, it was a change to one module. I pointed the OIDC flow at the new provider, tested the handshake once, and deployed the module once. All 45 applications moved to DUO without being opened, without being redeployed, and without knowing anything had happened. They were still doing the exact same thing they had done the whole time, which was trusting the identity the boundary handed them. The boundary just started getting that identity from somewhere new.
That is the moment the approach paid for itself. The first migration saved me from editing 45 codebases. The second one, the one nobody warned me about when I started, I got almost for free. That is not luck. That is what centralizing a cross-cutting concern buys you. You pay once, at the boundary, and every future change to that concern is paid once too.
When this pattern fits
I will be honest about the shape of the problem this solves, because it is not every problem.
It fits when:
- You have many apps with the same cross-cutting need, such as auth, logging, rate limiting, or header rewriting. The economics only work when "once" replaces "many."
- They sit behind a single extensibility point you control. IIS here, but nginx, Apache, or a reverse proxy and API gateway all offer their own version of the same seam. That is the real precondition, not the platform.
- The concern is genuinely orthogonal to the app's logic. Authentication qualifies, because the app does not care how you proved who you are, only that someone did. It does not fit when the thing you are trying to change lives deep inside each application's own logic. Then there is no boundary to stand on, and you really are looking at touching the source.
But when it does fit, it is one of the highest-leverage moves in legacy modernization. You get modern behavior, meaning modern C#, a current identity protocol, and a clean place to make future changes, in front of systems you never have to reopen. The old apps keep doing what they do well. The new requirement gets met in modern code, once. And the next requirement, the one you cannot see yet, gets a lot cheaper too.
That is the whole idea behind how I approach legacy systems. You do not always have to rewrite the past to meet the present. Sometimes you just have to find the right place to stand.