Fair warning before you settle in: this one's a bit of a slow burn, and it's more about process than any single dramatic fix. Grab a coffee anyway.

One of the engineers on our team hit a confusing native Azure error in his app, AIM, something to do with a startup failure that wasn't giving up any useful detail on its own. Nothing to do with authentication. I offered to help him trace it, and tracing it properly meant actually reading his code end to end rather than jumping straight to whichever function name looked closest to the stack trace.

I want to pause on that for a second, because it's funnier than it sounds. In a year where most of us, myself very much included, spend a good chunk of the day letting an AI draft the first pass of whatever we're building, sitting down and genuinely reading someone else's implementation, line by line, has quietly become a strange, almost old-fashioned skill. The kind of thing you used to just do as a developer, and now have to consciously choose to do, like taking the stairs. I did it anyway, because the error wasn't going to explain itself, and somewhere in that read-through I ended up in his config/auth.py, well past the part I actually needed for the bug.

The comparison that started it

His Auth Pattern, Compared to Mine

His auth.py looked different from the pattern I'd built into my own apps, and it took me a second read to work out why. Where mine leaned on the platform-level function key as the primary gate, with full JWT signature verification sitting on my own roadmap as a deliberate next step rather than something switched on from day one, his already had the fuller version wired in: real signature verification against Microsoft's own published keys, issuer and audience checks, the works, actually running on every route rather than just sitting nearby as intent.

I went back and put mine next to his, properly, not just a glance. And I'll say it plainly because it's true: his was more mature, further along the same road I'd already mapped out for my own apps than I was.

๐Ÿ™Œ

Worth flagging early: this isn't a story about finding something broken. His implementation simply reached a stage mine hadn't gotten to yet, and reading it properly moved my own timeline forward.

Why mine started simple

Why Mine Started Simple, On Purpose

My default approach across most of our internal APIs, Rating Engine included, is to build the thinnest version that works first, prove the actual business logic out, then mature the non-functional layers, auth, observability, retry behaviour, once the shape of the thing has settled. Function-key access is a real security layer on its own, and for an early-stage internal API talking to a small number of known callers, it's a tech debt I'm willing to live with in the early stage, vetted with the big boss first of course.

It's also just an accurate description of how this API portfolio got built in the first place. For a long stretch it was effectively one person, me, taking each of these apps straight from proof of concept to production, because that was faster than waiting on a dedicated hire. Auth maturity was always the second pass, not the first, just because there wasn't enough of a team yet to properly collab on both at the same time.

Seeing it already done, and done well, in a sibling app was less "here's a gap" and more "here's the version I was heading toward, already built, already proven."

Making sure I was reading it right

Asking Four AIs, and Getting the Same Answer Four Times

Before deciding to standardise on his pattern across the other APIs, I ran the comparison past four different AI models, Claude, ChatGPT, Gemini, and DeepSeek, mostly out of habit at this point. I don't trust a single model's read on anything auth-related, security is exactly the kind of topic where a confidently wrong answer looks identical to a confidently right one.

All four landed on the same verdict, independently: full JWKS-based signature verification, actually enforced on every route, is the pattern worth standardising on, and my own simpler starting point wasn't wrong, just an earlier rung on the same ladder his implementation had already climbed. That review doubled as a check on his implementation too, not just a comparison against mine, before either pattern became something the rest of the estate would be measured against. Genuinely useful, since four different models actually agreeing on something is rarer than you'd think. Ask any AI four separate times how to structure a folder and you'll get four different, mildly confident opinions.

What "more mature" actually means here

For Anyone Not Living in Auth Every Day: What Signature Verification Actually Buys You

๐Ÿ’ฌ Explain it like I'm five: a shared key versus a signed token

A function key is one shared secret. Every caller who has it looks identical to every other caller who has it, a bit like a building's shared door code, it tells you someone knew the code, not who they actually are. It's a real layer, and plenty of simple internal APIs run happily on it for years.

A signed JWT is closer to an ID card that's cryptographically tamper-evident. It states who it's for, who issued it, and when it expires, with a seal that visibly breaks if anyone tries to alter it. The API checks that seal is genuine, not just that a card is present, so now the request carries a Microsoft-signed identity bound specifically to this API, not just proof someone had the key. That's the extra maturity his implementation already had, and mine was heading toward.

The gap between the two matters most once an API is reachable by more than one distinguishable caller, or the data behind it gets more sensitive, or someone goes looking specifically for how identity is verified. Rating Engine was heading toward all three of those over time, which made this a good moment to close the gap rather than a later one.

Where this actually sits against Zero Trust

It's worth naming the framework this is actually moving toward, rather than just calling it "more mature" and leaving it vague. Microsoft's own security guidance is built around Zero Trust, and its first principle is "verify explicitly": authenticate and authorise based on real, available signal, every time, rather than assuming a request is trustworthy because of where it came from.

A function key on its own doesn't really do that. It answers "does this caller know a shared secret," which is a real check, but it's not an identity check, it can't tell you who or what is actually calling, only that they had the string. That's precisely the gap "verify explicitly" is aimed at closing, and it's why function-key-only was always meant to be a first layer, not a last one. His implementation had already closed that gap properly, per request, with a real identity behind every call rather than a shared password standing in for one. Bringing Rating Engine and another of our internal apps up to the same standard isn't just tidying up, it's a much closer fit for "verify explicitly" in the Zero Trust sense: instead of proving only that a caller knows a shared secret, every request now carries a signed, time-bound identity issued specifically for that API.

Funny enough, you don't even need Microsoft's own docs to arrive at this. I've scrolled past enough YouTube Shorts and Instagram Reels about vibe coding your way to a SaaS product at this point that the one piece of advice everyone seems to repeat, regardless of channel or accent, is some version of "if you're vibe coding this, at least do JWT." Nice when the internet's collective advice and an actual enterprise security framework land in exactly the same place.

A registration of its own

Why a Dedicated App Registration, Not Reusing One We Already Had

It would have been tempting to reuse an App Registration we already had elsewhere rather than set up a new one just for this. I went with a dedicated one instead, and it's worth explaining why, since the reasoning generalises well beyond this one API.

An App Registration isn't "attached" to a Function App by any infrastructure-level link, the only connection is the aud value a token carries, checked entirely by our own code. Reusing a registration that already serves some other purpose elsewhere means its tokens could, in principle, end up valid for whatever else happens to check that same audience too, purely because the value matches, not because anything about the token was actually meant for this API. Keeping a resource's identity dedicated to that resource alone avoids blast radius that doesn't need to exist, and a dedicated registration costs nothing extra to set up, no redirect URI, no client secret, just exposing an API under its own audience.

๐Ÿ”

Worth being precise about what this verification actually proves, and what it doesn't. The JWT check establishes that a request carries a genuine, Microsoft-issued token meant specifically for Rating Engine's audience. It doesn't, on its own, restrict which caller was able to obtain that token in the first place, that's a separate authorisation layer, and not something this refactor claims to have finished.

A decision worth naming honestly

Client Credentials, Not a Browser Login Flow

TRAVEL calling Rating Engine uses client-credentials, an app-only flow with no human signing in, matching an existing pattern TRAVEL already had for calling Dataverse. It avoids introducing a full login flow for what is fundamentally a backend-to-backend call. Actor identity, which person actually triggered a given action, keeps travelling as a trusted field from TRAVEL, same as before.

Making sure it actually held up

Making Sure It Actually Held Up, Not Just Reading Nicely

Changing the code and trusting the code are two different things, and this is the genuinely satisfying part of the whole exercise, you get to try to break your own thing on purpose. I wrote a small PowerShell smoke test and ran it against the dev environment: no token at all, a malformed token, a function key with no Bearer token attached, and, the fun one, a hand-built token with a fake signature, just to watch it get correctly rejected rather than take that on faith.

// Smoke test cases run against the dev Function App
  1. No Bearer token at all, expect 401
  2. Malformed token, expect 401
  3. Hand-built token with a fake signature, expect 401
  4. Valid function key, no Bearer token, expect 401
  5. A real, correctly signed token, correct audience, expect 200
Building a deliberately fake token, PowerShell
# simplified for readability here, the actual test properly
# base64url-encodes both segments, and uses RS256 in the header
# to match what our real tokens use, so the failure it triggers
# is a genuine forged-signature rejection, not an algorithm mismatch
$header  = '{"alg":"RS256","typ":"JWT"}'
$payload = '{"email":"test@example.com","oid":"test-oid", ... }'
$fakeToken = "$header.$payload.notarealsignature"

Invoke-WebRequest -Uri $url `
  -Headers @{ "Authorization" = "Bearer $fakeToken" } `
  -Method Get   # expect 401, not 200

The last case, the positive control, is the one I nearly skipped, and I want to be honest that skipping it would have been a real gap. A test suite that rejects everything passes the first four cases too. Without a genuine, correctly signed, correctly audienced token coming back with a clean 200, all I'd actually shown is that bad traffic gets blocked, not that legitimate traffic still works. Acquiring a real token through the actual client-credentials flow and watching it sail through cleanly was the part that let me actually call this done.

The part that ate the most time

The Azure Quirk That Cost the Most Time on the Night

Detour

The Application ID URI and the real aud claim aren't the same string

I'd configured the audience environment variable using the Application ID URI, the api://<client-id> form you get by default from Expose an API. Every request came back rejected with a log line reading "audience doesn't match." Pulling a real token from an actual client-credentials call and decoding it showed the aud claim carrying the bare client ID GUID, no api:// prefix at all. Turns out that's tied to the access token version rather than which flow requested it: v2.0 tokens carry the bare client ID as aud, v1.0 tokens can carry the Application ID URI instead. I hadn't checked the App Registration's own configured access-token version before setting this up, so capturing a real token and decoding it was what finally made the mismatch obvious.

The fix was an environment variable change, not a code change, once I'd confirmed nothing else depended on the other version. Worth flagging as a note for future-me: which form shows up is set by the App Registration's own configured token version, not something a caller chooses per request, so if that configuration ever changes, the audience may switch to the Application ID URI form instead. The audience check in code should accept both forms once there's any real chance of that, rather than continuing to chase a single environment variable value as the configuration changes.

๐Ÿ’ก

The actual lesson, generalised: don't configure an audience value from what the setup screen offers you by default. Acquire a real token the way the actual caller will, decode the payload, and read what's genuinely in the aud field.

Bringing another app along too

Another App's Turn, and a Different Kind of Deliberate

Once Rating Engine and TRAVEL were sorted, the natural next question was whether another one of our internal apps should be brought up to the same standard. Rather than assume, I had the pattern reviewed against its actual auth.py, its function_app.py, and its own style guide, and what came back was a good reminder that "different" doesn't always mean "behind."

Its auth setup was, quite deliberately, running on function-key-only access, with the fuller verification logic present in code but not yet wired into the request pipeline, documented plainly in its own style guide as an intentional early-stage choice. That's the same "start simple, mature later" philosophy I'd applied to Rating Engine, just at an earlier point on the same timeline. Given the broader goal of standardising one auth pattern across the app estate rather than running several slightly different ones, the call was to bring it up to the same maturity level as Rating Engine now, on the same terms, rather than leaving two internal APIs on two different standards indefinitely.

Easy assumption to make

It must be behind too, fix it the same way

Given the similar shape of the code, it would have been easy to assume it needed an identical emergency-style fix without actually reading its own documented reasoning first.

What reading it properly showed

A deliberate, documented, earlier stage

Its simpler setup was a considered choice, written down as such. The real work was standardising it forward, not correcting a mistake.

Writing it down

Turning a Good Pattern Into the Default, Not Just an Improvement

Standardising three apps individually is useful. It doesn't stop the next new API from being scaffolded with whichever earlier-stage pattern happens to be closest to hand as a starting template. That's the real reason his pattern is now written into our team's style guide as the default, not just something I personally carried across, on the strength of one good comparison, into a document the next person actually opens before building anything new.

It's grown into something more mechanical than a policy page since then, too. What started as a written-down decision is now a full build harness, mostly assembled with an AI agent, that gets fed directly to whatever's building a new endpoint. It's precise enough to include the exact project structure, the exact route-registration pattern, the exact five claims to validate, and a "quick checklist for a new project" an agent can tick off literally, not just a paragraph of intent someone has to interpret. New endpoints don't get built and then checked against the standard afterward, they get built with the standard already in the context window.

It states plainly that every new Azure Functions Python backend uses two stacked auth layers, the platform-level function key and application-level JWT signature verification, from day one, and that every route goes through a single protected_route() wrapper rather than a bare @app.route(...).

function_app.py, the pattern now written into the style guide
from config.auth import protected_route

@protected_route(app, route="business-units", methods=["GET"])
def business_units_list(req: func.HttpRequest) -> func.HttpResponse:
    """List business units."""
    return handle_list_business_units(req)

The five claims that actually get checked on every token are worth naming plainly, since they're easy to gesture at without saying what each one is actually for.

ClaimPlain EnglishWhat checking it confirms
expWhen this token stops being validYou're not trusting something long past its use-by date
iatWhen this token was issuedUseful for audit, not an access decision on its own
nbfWhen this token starts being validA token meant for later isn't being used early
issWhich tenant actually issued itIt genuinely came from our tenant, not somewhere else
audWhich specific API it's meant forA token issued for one system isn't being reused against another
โœ…

One line in the harness's own new-project checklist is worth calling out on its own: confirm the whole thing end to end with a real request before calling auth "done", code review alone isn't proof. That's the same lesson the forged-token test above was making, just now written down as a rule for whatever builds the next one, rather than something I have to remember to do myself each time.

Building a harness like this for a specific, repeatable slice of work is a genuinely good habit in 2026, and I don't think that's a controversial thing to say anymore. Pretty much everyone is vibe coding something at this point, the productivity gain is too real to ignore, and I'm not going to pretend otherwise. What a shared harness actually buys you isn't less AI involvement, it's consistency underneath it. The structure stays the same project to project, rather than turning into a pile of slightly different frameworks and architectural choices depending on which session built which piece. I've started doing this beyond just this one Azure Functions pattern too, a Static Web Apps harness, a cron-triggered Azure Functions harness, even a website design harness, and I'll probably write about those separately once they've been through a few real builds.

The clearest proof of it working so far: I used this exact harness to build a brand new endpoint for SHIELD, one of our other internal apps, and it was genuinely great. One prompt, essentially "read this harness and build the endpoint," and when I needed a new stored procedure added, I just handed over the DDL and said "add this new endpoint, this is the DDL for the stored proc," and it came out matching the exact same pattern as everything else, first try. Before having this written down properly, getting an AI to structure a new endpoint the same way as the last five took a fair bit of repeating myself each time. With vibe coding as the default now, keeping the structure consistent across a codebase isn't just tidiness for its own sake, it's what keeps debugging sane later. The alternative is opening a file six months from now going "what on earth is this, where do I even start, why is this one different," which is a much worse way to spend an afternoon than writing the harness once up front.

Kudos where it's due

Credit Where It's Due

Worth naming plainly why his implementation ended up ahead of mine, because it's a more interesting reason than a simple skill comparison. He builds primarily through AI assistance, and my own approach leans more on manually shaping code by hand. For an early-stage internal API, my instinct was to deliberately strip the auth layer down for speed, with a plan to circle back and rebuild the fuller version later. His approach was to trust and keep the fuller, more thoroughly verified pattern an AI assistant produced by default, rather than simplifying it down. In this case, preserving that default was exactly the right call, and it got him to a more mature result sooner than my own more hands-on, staged approach did.

That's a genuinely useful thing to notice in 2026. The credit here isn't for grinding through the harder path, it's for recognising a solid default and choosing not to second-guess it, which is its own real skill, and a different one from the manual, staged approach I'd defaulted to myself. His implementation is the reference the rest of our estate is now measured against for exactly that reason.

The part that still feels a bit unreal

Less Than a Day, Twice Over, Across Two Completely Different Stacks

One more thing worth writing down plainly rather than burying in a parenthetical somewhere above: from deciding to standardise on this pattern to actually having it live, the API layer refactor itself took less than a day. Not a sprint, not a week set aside for it. A single day: JWKS verification wired in, the protected_route wrapper rolled out across every route, PyJWT bumped, tested against the smoke suite above, done.

I want to be honest about what that actually means, because a few years ago it wouldn't have been close to true. The same scope of change, touching every route in an API layer, changing how every single request authenticates, verifying it properly rather than just believing it works, would have been a multi-day job at best for me working alone, and a multi-week one if I'm honest about how much of that time usually goes into re-reading Microsoft's own identity documentation rather than actually writing code. Having AI handle the mechanical parts, the wiring, the boilerplate, applying the same change consistently across every route, while I made the actual calls on architecture, trade-offs, and what "done" meant, is the entire reason a day was even a realistic target.

And it wasn't just the API layer. Two of our internal frontends call into these APIs directly, one built in React with a Node backend, the other genuinely just vanilla HTML and JavaScript, about as different a pair of stacks as you could pick on purpose. Updating both to actually acquire and attach a real token instead of relying on the function key alone was also under a day each. Three codebases, spanning two unrelated frontend stacks and one backend, brought up to the same authentication standard in under three working days total. That's the kind of number that would have sounded made up to me a few years ago, and it's genuinely the part of this whole exercise that still feels slightly unreal writing down now.

What this taught me

What This Taught Me

The thing that actually stayed with me isn't the auth pattern itself, JWKS verification is well-understood, standardising three apps onto it was mostly mechanical once the decision was made. It's how the whole thing started: not a scheduled review, not a roadmap item coming due, just helping someone with an unrelated bug and choosing to actually read the code properly instead of skimming for the one function that mattered.

That's an easy habit to lose lately, when so much of the day is spent reviewing what an AI already drafted rather than reading something a person wrote start to finish. I don't think the answer is to distrust AI-assisted code, I use it constantly, myself included. I think the answer is that reading someone else's actual implementation, properly, occasionally still turns up something a summary or a diff view never would, a design choice, a level of care, a "huh, this is better than mine" moment worth sitting with rather than rushing past.

The best outcome of a debugging session isn't always fixing the bug. Sometimes it's noticing that someone else already solved a problem you hadn't gotten around to yet, and having the good sense to say so.

The other half of it is just as real: writing the pattern down mattered more than adopting it three times over did. Three individual apps brought up to the same standard protect three individual apps. A rule that says "every new API gets two stacked auth layers, from day one, following his pattern" written into the document the next person actually opens before scaffolding a fourth one, that's the part with a chance of outlasting this specific comparison.