<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Virendra Vyas</title>
    <description>The latest articles on DEV Community by Virendra Vyas (@virendravyas).</description>
    <link>https://dev.to/virendravyas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F212700%2F7d92c2bb-d57a-439a-91a3-7eaa153059fb.jpg</url>
      <title>DEV Community: Virendra Vyas</title>
      <link>https://dev.to/virendravyas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/virendravyas"/>
    <language>en</language>
    <item>
      <title>My API Was Logging People's National Insurance Numbers. I Never Told It To.</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/virendravyas/my-api-was-logging-peoples-national-insurance-numbers-i-never-told-it-to-2gn2</link>
      <guid>https://dev.to/virendravyas/my-api-was-logging-peoples-national-insurance-numbers-i-never-told-it-to-2gn2</guid>
      <description>&lt;p&gt;I never wrote a single line of code that logged anyone's National Insurance Number. That's what made this one uncomfortable to find.&lt;/p&gt;

&lt;p&gt;GovBridge sits in front of several UK government APIs, and some of those endpoints take genuinely sensitive identifiers as part of the request - a NINO for an Income Tax lookup, a VAT registration number, a Unique Taxpayer Reference. Reasonable enough. That's what the underlying government APIs actually require.&lt;/p&gt;

&lt;p&gt;The problem wasn't the request. It was everything that happened after the request, without me ever asking it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it was actually leaking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most of these identifiers weren't sitting quietly in a request body, where sensible logging conventions tend to leave things alone. They were in the URL path -&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/individuals/{nino}/income-tax/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;because that's how the government's own APIs are structured, and a gateway sitting in front of them naturally mirrors that shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And request paths get logged. By default. Almost everywhere. Your web server logs them. Your framework's request logging middleware logs them. Any APM or monitoring tool you've wired in logs them. None of that logging was written with "this specific path segment happens to be someone's tax identifier" in mind, because generic logging infrastructure has no way to know that.&lt;/p&gt;

&lt;p&gt;So without a single deliberate &lt;code&gt;Log.Info(nino)&lt;/code&gt; anywhere in my codebase, a real person's National Insurance Number was sitting in plain text in application logs, purely as a side effect of URL structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is so easy to miss&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nobody sets out to log PII. It happens because logging infrastructure and API design are built by different parts of your brain, on different days, and they never actually talk to each other. Your route design is driven by REST conventions and what the upstream API expects. Your logging setup is driven by "I want visibility into what's happening in production." Neither decision, on its own, looks wrong. The collision between them is invisible until you specifically go looking for it.&lt;/p&gt;

&lt;p&gt;And this isn't a government-API-specific problem. Any system where a sensitive identifier - an account number, a medical record ID, anything regulators would call out, ends up as a path segment or query parameter has the same exposure, completely independent of what industry you're in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The real fix isn't "stop putting sensitive data in URLs" sometimes you don't control that, especially when you're mirroring an upstream API's own routing structure, the way GovBridge does.&lt;/p&gt;

&lt;p&gt;The fix is treating log redaction as its own explicit layer, not an afterthought bolted onto whatever logging framework you're using. Concretely, that meant building a dedicated redaction component - a &lt;code&gt;SensitiveDataRedactor&lt;/code&gt; - that knows the shape of the identifiers that matter (NINO, VRN, UTR, and anything added to that list going forward) and scrubs them from anything about to be written to a log, regardless of whether they showed up in a path, a query string, or a header.&lt;/p&gt;

&lt;p&gt;The important design choice: this lives centrally, in the shared core of the application, not scattered across individual controllers or endpoints. A rule like "don't log this identifier" is exactly the kind of thing that's useless if you have to remember to apply it every time you add a new endpoint. It only works if it's structurally impossible to forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I'm writing this up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because I suspect a meaningful number of people reading this have some version of this exact gap sitting in their own systems right now, undetected, for the same reason mine was: it doesn't look like a bug from either side. The routing looks fine. The logging looks fine. It's only wrong when you look at both at once.&lt;/p&gt;

&lt;p&gt;If you've got sensitive identifiers anywhere in your URL structure, and if you're integrating with any regulated API, you probably do, it's worth a specific, deliberate check: open your actual logs, not your code, and see what's really sitting in them.&lt;/p&gt;

</description>
      <category>security</category>
      <category>dotnet</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I Rate-Limited My Own API. Then I Found a Way to Bypass It Myself.</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:39:53 +0000</pubDate>
      <link>https://dev.to/virendravyas/i-rate-limited-my-own-api-then-i-found-a-way-to-bypass-it-myself-2156</link>
      <guid>https://dev.to/virendravyas/i-rate-limited-my-own-api-then-i-found-a-way-to-bypass-it-myself-2156</guid>
      <description>&lt;p&gt;Rate limiting feels like a solved problem. You pick a partition key, set a window, wire up the middleware, and move on. I did exactly that on GovBridge - a gateway sitting in front of several UK government APIs, and for a while I genuinely thought it was done.&lt;/p&gt;

&lt;p&gt;Then I went looking for edge cases in my own system, the way you're supposed to before you trust something with production traffic. And I found one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My rate limiter was partitioning requests by the raw x-api-key header value - a completely reasonable-sounding default. Different key, different bucket, different limit. Simple.&lt;/p&gt;

&lt;p&gt;Except: nothing was actually checking whether that key was valid before the rate limiter ran. Authentication happened later in the pipeline. Which meant the rate limiter was perfectly happy to hand out a fresh limit bucket to any string you put in that header - real key or not.&lt;/p&gt;

&lt;p&gt;If you wanted to bypass the limit entirely, you didn't need to break anything clever. You just needed to change the header value on every request. Each "new" fake key got its own untouched allowance. The limiter was doing exactly what I told it to do - it just wasn't protecting what I actually wanted protected.&lt;/p&gt;

&lt;p&gt;This is the kind of bug that doesn't show up in normal testing, because normal testing uses real keys. It only shows up when you deliberately try to misuse your own system the way someone with bad intentions eventually would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is an easy trap to fall into&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The instinct to partition by "whatever's in the header" feels natural, because it's the first thing available and it looks like identity. But a header value isn't identity — it's just a string someone sent you. Identity is what your authentication layer confirms after checking that string against something real.&lt;/p&gt;

&lt;p&gt;Rate limiting on the wrong side of that line protects a piece of text, not a user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two changes, and they had to happen together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication moved ahead of rate limiting in the middle-ware pipeline&lt;/strong&gt; - so by the time the limiter runs, the request has already been through validation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The rate limiter's partition key changed from the raw header to the authenticated claim&lt;/strong&gt; - the identity that validation actually confirmed, not the string that arrived on the wire.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now an invalid key doesn't get a fresh bucket to exploit, it gets rejected by authentication before rate limiting ever sees it. And a real, valid key can't be rotated to dodge its own limit, because the limiter is now counting the thing being limited, not an arbitrary label attached to the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I'm writing this up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not because the fix is clever, it's genuinely simple, in hindsight almost embarrassingly so. I'm writing it up because "partition by header, not by verified identity" is an easy default to reach for, and I don't think I'm the only person who's reached for it without noticing the gap.&lt;/p&gt;

&lt;p&gt;If you've built rate limiting or throttling into anything - an API, a login flow, a signup form — it's worth a specific check: is the thing you're limiting actually confirmed to exist, or are you just limiting a string that showed up?&lt;/p&gt;

&lt;p&gt;Cheaper to find that gap yourself than to have someone else find it for you.&lt;/p&gt;

</description>
      <category>security</category>
      <category>dotnet</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GovBridge UK: Why I Built a Unified API Gateway for UK Government Data (HMRC, Companies House, DVLA, Land Registry)</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Fri, 14 Aug 2026 15:59:54 +0000</pubDate>
      <link>https://dev.to/virendravyas/title-why-i-built-a-unified-api-gateway-for-uk-government-data-hmrc-companies-house-dvla-land-384c</link>
      <guid>https://dev.to/virendravyas/title-why-i-built-a-unified-api-gateway-for-uk-government-data-hmrc-companies-house-dvla-land-384c</guid>
      <description>&lt;p&gt;If you've ever integrated with more than one UK government API, you already know the pain: each one has its own auth flow, its own error format, its own rate limits, its own quirks in sandbox vs production. HMRC alone has a dozen+ API families under Making Tax Digital, each slightly different from the last.&lt;/p&gt;

&lt;p&gt;I got tired of solving this from scratch on every project, so I built GovBridge UK — a single gateway that normalizes HMRC MTD, Companies House, DVLA, and Land Registry behind one consistent interface.&lt;/p&gt;

&lt;p&gt;What it handles:&lt;/p&gt;

&lt;p&gt;A single error envelope across all four government APIs, instead of learning four different failure formats OAuth token management and refresh handled once, not per-integration 40+ working HMRC endpoints already wired up and sandbox-tested (Income Tax MTD, VAT, CIS, PAYE, Property/Savings income, and more) A developer portal (React) so you can explore and test endpoints without reading four separate sets of gov.uk docs&lt;/p&gt;

&lt;p&gt;Stack: .NET 8/10, ASP.NET Core, PostgreSQL, Redis for token/session caching, React/TypeScript for the portal.&lt;/p&gt;

&lt;p&gt;It's still evolving - security hardening and the CT600/GovTalk integration are in progress. If you've built anything against HMRC's MTD APIs, Companies House, or DVLA, I'd genuinely like to hear how you handled the integration overhead - did you roll your own, or is there something out there I've missed?&lt;/p&gt;

</description>
      <category>api</category>
      <category>hmrc</category>
      <category>ukgov</category>
      <category>gov</category>
    </item>
    <item>
      <title>Swagger UI Is Showing the Wrong Auth Requirement on Every Endpoint — Here's the Fix</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Tue, 11 Aug 2026 23:57:15 +0000</pubDate>
      <link>https://dev.to/virendravyas/swagger-ui-is-showing-the-wrong-auth-requirement-on-every-endpoint-heres-the-fix-hlb</link>
      <guid>https://dev.to/virendravyas/swagger-ui-is-showing-the-wrong-auth-requirement-on-every-endpoint-heres-the-fix-hlb</guid>
      <description>&lt;p&gt;Edit:Updated after a sharp comment from a reader — the original version used reflection over attributes, which misses RequireAuthorization() on minimal APIs and fallback policies, and collapsed multi-scheme requirements incorrectly. Fixed below.&lt;/p&gt;

&lt;p&gt;If you've wired up Swashbuckle with AddSecurityDefinition and AddSecurityRequirement following the standard tutorial, you've probably hit this: every single endpoint in Swagger UI shows the padlock icon and demands the same auth scheme - even endpoints that don't need auth at all, or that need a different scheme entirely. This gets worse fast once you're running more than one JWT scheme in the same API.&lt;/p&gt;

&lt;p&gt;Why this happens&lt;/p&gt;

&lt;p&gt;The common tutorial snippet looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSwaggerGen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSecurityDefinition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiSecurityScheme&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecuritySchemeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;BearerFormat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;In&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ParameterLocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSecurityRequirement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiSecurityRequirement&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiSecurityScheme&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Reference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiReference&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReferenceType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Bearer"&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AddSecurityRequirement at this level applies globally - to every operation in the generated spec, regardless of whether the underlying endpoint actually requires that scheme, or requires a different one, or requires no auth at all. Swashbuckle has no way to know your intent unless you tell it per-operation.&lt;/p&gt;

&lt;p&gt;The first attempt (and why it wasn't enough)&lt;/p&gt;

&lt;p&gt;My first fix was an IOperationFilter that reflected over AuthorizeAttribute and AllowAnonymousAttribute on the controller/action. That works fine for typical MVC controllers — but a reader pointed out two real gaps:&lt;/p&gt;

&lt;p&gt;Minimal APIs and fallback policies don't show up via reflection. RequireAuthorization() on a minimal API endpoint, or a global AuthorizationOptions.FallbackPolicy, protect the endpoint at runtime without any AuthorizeAttribute being declared. Reflection-based detection reports these as anonymous, which is wrong.&lt;br&gt;
Multi-scheme requirements were being collapsed incorrectly. In OpenAPI, schemes inside one requirement object mean AND (all required together); separate requirement objects mean OR (any one satisfies it). The original code took schemeNames.First(), silently dropping any additional required schemes from the spec.&lt;br&gt;
The fix: read runtime metadata, not attributes&lt;/p&gt;

&lt;p&gt;The more reliable source is ApiDescription.ActionDescriptor.EndpointMetadata, which reflects what's actually enforced at runtime — including minimal API policies and fallback policies — not just what's declared via attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecurityRequirementsOperationFilter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOperationFilter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OpenApiOperation&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OperationFilterContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;endpointMetadata&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiDescription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActionDescriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndpointMetadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authorizeData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;endpointMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAuthorizeData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hasAnonymous&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;endpointMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAllowAnonymous&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasAnonymous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// explicitly anonymous, no padlock&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;authorizeData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// No explicit [Authorize] - check if a fallback policy applies&lt;/span&gt;
            &lt;span class="c1"&gt;// (inject IAuthorizationPolicyProvider or pass AuthorizationOptions&lt;/span&gt;
            &lt;span class="c1"&gt;// in to resolve FallbackPolicy here if one is configured)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Group scheme names per AuthorizeData entry - each entry's schemes&lt;/span&gt;
        &lt;span class="c1"&gt;// are its own AND group; multiple entries are OR'd as separate&lt;/span&gt;
        &lt;span class="c1"&gt;// requirement objects, matching OpenAPI semantics.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;requirementGroups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authorizeData&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationSchemes&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringSplitOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveEmptyEntries&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;StringSplitOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimEntries&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Distinct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StringArrayComparer&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;requirementGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;requirementGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Bearer"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Security&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requirementGroups&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;requirement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OpenApiSecurityRequirement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scheme&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiSecurityScheme&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;Reference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OpenApiReference&lt;/span&gt;
                        &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReferenceType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scheme&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StringArrayComparer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEqualityComparer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;]&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;SequenceEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;GetHashCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Aggregate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetHashCode&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it the same way as before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSwaggerGen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... AddSecurityDefinition calls for each scheme (e.g. "MainScheme", "UserTokenScheme")&lt;/span&gt;

    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SecurityRequirementsOperationFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now operation.Security correctly represents:&lt;/p&gt;

&lt;p&gt;Anonymous endpoints ([AllowAnonymous] or no auth metadata at all) → no padlock&lt;br&gt;
Single-scheme endpoints → one requirement object, one scheme&lt;br&gt;
Multi-scheme AND ([Authorize(AuthenticationSchemes = "A,B")]) → one requirement object containing both schemes&lt;br&gt;
Multi-scheme OR (stacked [Authorize] attributes with different schemes) → separate requirement objects, one per attribute&lt;/p&gt;

&lt;p&gt;This is closer to the actual OpenAPI spec semantics, not just a padlock that happens to look right for the common case.&lt;/p&gt;

&lt;p&gt;Fallback policies still need explicit handling&lt;/p&gt;

&lt;p&gt;If you use a global FallbackPolicy (protects any endpoint without explicit authorization metadata), the filter above deliberately does nothing for those endpoints rather than guessing — you'll want to resolve the policy's scheme requirements yourself (via IAuthorizationPolicyProvider) and apply them the same way as an explicit [Authorize]. I've left this as a TODO above rather than baking in an assumption about your policy setup, since fallback policies vary a lot between projects.&lt;/p&gt;

&lt;p&gt;Why this matters more with multiple schemes&lt;/p&gt;

&lt;p&gt;If you're only running a single JWT scheme, the blanket AddSecurityRequirement approach is merely imprecise — annoying, but not actively misleading. Once you're running two schemes side by side (see my earlier post on dual JWT Bearer schemes), the global approach becomes actively wrong: it tells every consumer of your Swagger UI to authenticate with the wrong token on some endpoints, or hides a required second token entirely, which is exactly the kind of thing that sends other developers down the wrong debugging path.&lt;/p&gt;

&lt;p&gt;Checklist&lt;br&gt;
 Does Swagger UI show a padlock only on endpoints that actually require auth?&lt;br&gt;
 If you have [AllowAnonymous] endpoints, do they correctly show no lock?&lt;br&gt;
 If you're running multiple schemes, does each protected endpoint show the correct scheme(s) — not just "a" scheme?&lt;br&gt;
 Do multi-scheme AND requirements show as one requirement object with both schemes, not just the first one?&lt;br&gt;
 Do you have any minimal API endpoints or a fallback policy, and if so, are they handled explicitly rather than assumed anonymous?&lt;br&gt;
 Have you tested the Authorize button end-to-end — does the token you enter actually get attached to requests for the endpoint you're testing?&lt;/p&gt;

&lt;p&gt;This is a small amount of extra setup for a much more honest Swagger UI — and it stops other developers (or future you) from trusting a spec that's quietly lying about what each endpoint needs.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>dotnetcore</category>
      <category>swagger</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Two JWT Bearer Schemes, One API: A Real ASP.NET Core Setup</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Tue, 11 Aug 2026 23:47:09 +0000</pubDate>
      <link>https://dev.to/virendravyas/two-jwt-bearer-schemes-one-api-a-real-aspnet-core-setup-92k</link>
      <guid>https://dev.to/virendravyas/two-jwt-bearer-schemes-one-api-a-real-aspnet-core-setup-92k</guid>
      <description>&lt;p&gt;Most ASP.NET Core auth tutorials assume a single JWT scheme: one token, one identity, one &lt;code&gt;[Authorize]&lt;/code&gt; and you're done. Real systems don't always fit that shape. On a production system I worked on, we needed a second, independently-issued JWT for a narrower purpose alongside the main session token — and getting the two schemes to coexist cleanly (without silently breaking claim lookups) took longer than it should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two schemes
&lt;/h2&gt;

&lt;p&gt;The main API surface authenticates with a standard &lt;code&gt;Authorization: Bearer&lt;/code&gt; token issued at login. Separately, a specific set of endpoints needed to authenticate a &lt;em&gt;secondary&lt;/em&gt; JWT — issued for a narrower, scoped context — carried in a custom header rather than the standard &lt;code&gt;Authorization&lt;/code&gt; header. Trying to force both use cases through a single scheme meant either overloading one token with responsibilities it shouldn't have, or writing brittle conditional logic inside a single &lt;code&gt;JwtBearerEvents&lt;/code&gt; handler. Registering two named schemes turned out to be far cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering both schemes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJwtBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MainScheme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuerSigningKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// issuer/audience/key config here&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJwtBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UserTokenScheme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MapInboundClaims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// more on this below&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuerSigningKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Events&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;JwtBearerEvents&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;OnMessageReceived&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-User-Token"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each scheme gets a distinct name, and &lt;code&gt;[Authorize(AuthenticationSchemes = "UserTokenScheme")]&lt;/code&gt; on a controller or action routes that specific endpoint to check the right token, extracted from the right place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MapInboundClaims gotcha
&lt;/h2&gt;

&lt;p&gt;This is the part that actually cost the time. By default, &lt;code&gt;JwtBearerOptions.MapInboundClaims&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, which means ASP.NET Core silently remaps standard JWT claim types on the way in — &lt;code&gt;sub&lt;/code&gt; becomes &lt;code&gt;ClaimTypes.NameIdentifier&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; becomes &lt;code&gt;ClaimTypes.Email&lt;/code&gt;, and so on, using the legacy .NET claim type URIs.&lt;/p&gt;

&lt;p&gt;If your code reads claims by their raw JWT names (&lt;code&gt;context.User.FindFirst("sub")&lt;/code&gt;), this remapping means the claim you're looking for silently isn't there under that name anymore — no exception, no error, just a &lt;code&gt;null&lt;/code&gt; where you expected a value. It's a quiet failure mode that's easy to burn an afternoon on, because everything &lt;em&gt;looks&lt;/em&gt; correct: the token is valid, the request is authenticated, but a specific claim lookup keeps failing.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MapInboundClaims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this set, &lt;code&gt;HttpContext.User.Claims&lt;/code&gt; reflects the JWT's claim types exactly as they were issued — no remapping. Worth deciding deliberately per-scheme rather than hitting it by accident, especially if one scheme's tokens were issued by code that assumes the raw claim names and the other assumes the mapped &lt;code&gt;ClaimTypes.*&lt;/code&gt; values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the frontend agnostic
&lt;/h2&gt;

&lt;p&gt;On the client side, permission and identity checks shouldn't need to know or care which scheme authenticated a given request. A thin context/helper layer that reads claims generically (rather than hardcoding assumptions about which scheme produced them) keeps this detail contained to the backend, where it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Are your schemes named explicitly, with &lt;code&gt;[Authorize(AuthenticationSchemes = "...")]&lt;/code&gt; used per endpoint rather than relying on a default?&lt;/li&gt;
&lt;li&gt;[ ] Have you checked &lt;code&gt;MapInboundClaims&lt;/code&gt; on each scheme if claim lookups aren't behaving as expected?&lt;/li&gt;
&lt;li&gt;[ ] Is the second token's source (header, cookie, query string) documented clearly for other developers touching the code?&lt;/li&gt;
&lt;li&gt;[ ] Are validation parameters (issuer, audience, lifetime, signing key) configured independently per scheme, not assumed to be shared?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running two JWT schemes side by side isn't exotic once it's set up, but the defaults in ASP.NET Core's &lt;code&gt;JwtBearerOptions&lt;/code&gt; — particularly &lt;code&gt;MapInboundClaims&lt;/code&gt; — are exactly the kind of thing that looks fine until it silently isn't.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>security</category>
      <category>csharp</category>
    </item>
    <item>
      <title>What HMRC's Fraud Prevention Headers Actually Require (That the Docs Don't Tell You)</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Tue, 11 Aug 2026 22:58:27 +0000</pubDate>
      <link>https://dev.to/virendravyas/what-hmrcs-fraud-prevention-headers-actually-require-that-the-docs-dont-tell-you-1kn9</link>
      <guid>https://dev.to/virendravyas/what-hmrcs-fraud-prevention-headers-actually-require-that-the-docs-dont-tell-you-1kn9</guid>
      <description>&lt;p&gt;If you've built against HMRC's Making Tax Digital APIs, you've seen the phrase "Fraud Prevention Headers" in the docs and probably assumed it was a checkbox exercise — a handful of headers, a quick lookup table, done. It isn't. Get these wrong and HMRC either silently downgrades your submission's trust score or rejects it outright with an error message that tells you almost nothing about which header caused it. This post covers what actually trips people up when implementing this in a real .NET backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;HMRC requires every call to their MTD APIs (VAT, ITSA, and others) to carry a set of &lt;code&gt;Gov-Client-*&lt;/code&gt; and &lt;code&gt;Gov-Vendor-*&lt;/code&gt; headers. The stated purpose is fraud prevention — HMRC wants a fingerprint of the originating device and software, not just the taxpayer's credentials. This is mandatory, not optional, and HMRC does run automated conformance checks against your header data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha nobody's docs make clear: server-side vs browser-side
&lt;/h2&gt;

&lt;p&gt;This is the part that catches out backend-only implementations. A chunk of the required headers describe the &lt;strong&gt;end user's device and browser&lt;/strong&gt;, not your server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Gov-Client-Device-ID&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Gov-Client-Screens&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Gov-Client-Window-Size&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Gov-Client-Browser-JS-User-Agent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Gov-Client-Timezone&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your architecture is a typical SPA-calls-API-calls-HMRC setup, these values have to be captured &lt;strong&gt;client-side&lt;/strong&gt;, passed to your backend, and then relayed onward — you can't reconstruct a meaningful device fingerprint from inside an ASP.NET Core service sitting in Azure. Header values like &lt;code&gt;Gov-Client-Device-ID&lt;/code&gt; need to be a stable per-device identifier generated and persisted in the browser (typically in local storage), not a GUID your server invents per session.&lt;/p&gt;

&lt;p&gt;The headers that genuinely are yours to generate server-side are the &lt;code&gt;Gov-Vendor-*&lt;/code&gt; set — things like &lt;code&gt;Gov-Vendor-Version&lt;/code&gt;, &lt;code&gt;Gov-Vendor-Product-Name&lt;/code&gt;, &lt;code&gt;Gov-Vendor-License-IDs&lt;/code&gt; — since those describe your software, not the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structuring it in .NET
&lt;/h2&gt;

&lt;p&gt;A clean way to handle this is a small pipeline: capture on the client, forward via a custom header on your own API, then a &lt;code&gt;DelegatingHandler&lt;/code&gt; that assembles the full HMRC header set before the outbound &lt;code&gt;HttpClient&lt;/code&gt; call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HmrcFraudHeaderHandler&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DelegatingHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IHttpContextAccessor&lt;/span&gt; &lt;span class="n"&gt;_httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HmrcFraudHeaderHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IHttpContextAccessor&lt;/span&gt; &lt;span class="n"&gt;httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_httpContextAccessor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;HttpRequestMessage&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Client-supplied values, forwarded from the frontend&lt;/span&gt;
        &lt;span class="nf"&gt;CopyHeaderIfPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Gov-Client-Device-ID"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;CopyHeaderIfPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Gov-Client-Screens"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;CopyHeaderIfPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Gov-Client-Window-Size"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;CopyHeaderIfPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Gov-Client-Timezone"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Server-generated values, describing your software&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gov-Client-Connection-Method"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WEB_APP_VIA_SERVER"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gov-Vendor-Version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"yourapp=1.4.0"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gov-Vendor-Product-Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"YourApp"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;CopyHeaderIfPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HttpRequestMessage&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryAddWithoutValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important design decision here is the split: your frontend collects device data once per session and sends it as ordinary headers to &lt;em&gt;your&lt;/em&gt; API, and this handler is the only place that knows how to translate that into HMRC's exact header contract. That keeps your controllers and services blissfully unaware of HMRC's header spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually see when it's wrong
&lt;/h2&gt;

&lt;p&gt;HMRC doesn't always give you a clean 400. In sandbox testing, malformed or missing fraud prevention headers typically show up as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;400&lt;/code&gt; with a vague &lt;code&gt;INVALID_HEADER&lt;/code&gt; or &lt;code&gt;MISSING_HEADER&lt;/code&gt; code but no indication of &lt;em&gt;which&lt;/em&gt; header&lt;/li&gt;
&lt;li&gt;Submissions that succeed but get flagged for manual review on HMRC's side (invisible to you at integration time)&lt;/li&gt;
&lt;li&gt;Inconsistent behavior between sandbox and production — sandbox is more forgiving&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical fix is to test against HMRC's official &lt;a href="https://developer.service.hmrc.gov.uk" rel="noopener noreferrer"&gt;Test Fraud Prevention Headers&lt;/a&gt; validation endpoint before you ever point at the live API — it will tell you precisely which header failed and why, which the production API won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Are &lt;code&gt;Gov-Client-*&lt;/code&gt; device/browser headers actually coming from the client, not fabricated server-side?&lt;/li&gt;
&lt;li&gt;[ ] Is &lt;code&gt;Gov-Client-Device-ID&lt;/code&gt; persisted per-device (not regenerated every session)?&lt;/li&gt;
&lt;li&gt;[ ] Are &lt;code&gt;Gov-Vendor-*&lt;/code&gt; headers static and versioned correctly with each release?&lt;/li&gt;
&lt;li&gt;[ ] Have you run your header set through HMRC's validation endpoint, not just the live sandbox?&lt;/li&gt;
&lt;li&gt;[ ] Does your timezone header format match IANA format exactly (e.g. &lt;code&gt;Europe/London&lt;/code&gt;), not an offset?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fraud Prevention Headers are one of those HMRC requirements that look like paperwork until you realize they're a genuine architectural decision — where in your stack does "the browser" end and "your server" begin, and how do you carry that boundary through cleanly. Get the split right early and it stops being a recurring headache.&lt;/p&gt;

</description>
      <category>hmrc</category>
      <category>api</category>
      <category>fraudheaders</category>
      <category>ukgov</category>
    </item>
    <item>
      <title>What building against HMRC's MTD APIs actually looks like in 2026 (the undocumented gotchas nobody warns you about)</title>
      <dc:creator>Virendra Vyas</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:32:02 +0000</pubDate>
      <link>https://dev.to/virendravyas/what-building-against-hmrcs-mtd-apis-actually-looks-like-in-2026-the-undocumented-gotchas-nobody-dc</link>
      <guid>https://dev.to/virendravyas/what-building-against-hmrcs-mtd-apis-actually-looks-like-in-2026-the-undocumented-gotchas-nobody-dc</guid>
      <description>&lt;p&gt;I spent a recent session wiring up close to 20 HMRC Making Tax Digital endpoints — VAT, Income Tax MTD, CIS, Property Business, Partner Income, and a chunk of the legacy PAYE family — against the sandbox environment. The official docs are decent once you're on the right page. Getting to the right page is the hard part.&lt;/p&gt;

&lt;p&gt;Here are the specific things that cost real debugging time, in case they save someone else the same hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Charges" isn't a flat resource — it's charges/pensions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're using the generic pattern most of HMRC's "Individuals X Income" APIs follow — GET /individuals/{category}/{nino}/{taxYear} — it's tempting to assume every category slots into that same shape. Most do. Charges doesn't.&lt;/p&gt;

&lt;p&gt;The Individuals Charges (MTD) API is specifically about pension charges, and the real path has an extra segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /individuals/charges/pensions/{nino}/{taxYear}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not /individuals/charges/{nino}/{taxYear}. That extra /pensions/ isn't mentioned anywhere obvious — you find it by actually reading the endpoint list in the Developer Hub docs page for that specific API, not by pattern-matching from a working example elsewhere.&lt;/p&gt;

&lt;p&gt;Also worth knowing: this endpoint is versioned separately from most of the income-category family — it needs Accept: application/vnd.hmrc.3.0+json, not the 2.0 that dividends/pensions/foreign income use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reliefs isn't one API — it's five&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same trap, worse. There's no single "reliefs" resource at all. The Individuals Reliefs API splits into five genuinely separate sub-resources, each with its own path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /individuals/reliefs/investment/{nino}/{taxYear}
GET /individuals/reliefs/other/{nino}/{taxYear}
GET /individuals/reliefs/foreign/{nino}/{taxYear}
GET /individuals/reliefs/pensions/{nino}/{taxYear}
GET /individuals/reliefs/charitable-giving/{nino}/{taxYear}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've built a generic "fetch by category name" tool the way I had, none of the obvious category names (reliefs, relief) will work — you need the actual sub-resource name, which again only shows up if you read the specific API's endpoint list rather than inferring it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 404 doesn't always mean "wrong path" — sometimes it means "empty test user"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one's a genuine trap for anyone testing in sandbox: if you get a 404 on a category you already know works (say, dividends-income, which you tested five minutes ago), and you're now testing it against a different NINO, the 404 might just mean that test user has no seeded data for that category — not that your integration broke.&lt;/p&gt;

&lt;p&gt;The fix is boring but important: before debugging a path, re-test a known-good category against the same NINO. If that also fails, it's the test user, not your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandbox subscriptions are separate from API existence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Several endpoints that returned 404 turned out to be a subscription gap, not a path problem — the app registered in the Developer Hub simply wasn't subscribed to that specific API version. HMRC's sandbox doesn't always distinguish "not subscribed" from "genuinely not found" in its error responses, which makes this one of the more time-costly failure modes to diagnose, since the fix (go subscribe in the Developer Hub) doesn't look anything like what the error message suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some APIs are gated by a future tax year, and the error doesn't say why&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Individuals Partner Income API — a partner's share of partnership profit, reported through their own Self Assessment — only supports tax years from 2026-27 onward. Test it with 2024-25 (the reasonable default for most other MTD APIs) and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"RULE_TAX_YEAR_NOT_SUPPORTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The tax year specified does not lie within the supported range"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No indication of which years are actually supported. The only way to know is to check the example value HMRC shows in the docs (2026-27) and infer that's the floor — the API itself won't tell you.&lt;/p&gt;

&lt;p&gt;Stateful sandbox testing doesn't always require a "real" registered record&lt;/p&gt;

&lt;p&gt;One of the more useful discoveries: some stateful APIs (Property Business's period-summary create/fetch cycle, for example) will accept a synthetic ID matching the expected format, even if that ID doesn't correspond to a business that's actually registered anywhere. The sandbox stores it fresh on create and retrieves it on fetch, purely self-contained. Others — CIS Deductions, notably — behave inconsistently: create succeeds, but read/amend/delete against the exact same ID return MATCHING_RESOURCE_NOT_FOUND, which looks like a genuine sandbox bug rather than expected behavior.&lt;/p&gt;

&lt;p&gt;The lesson: don't assume all "stateful" APIs behave identically. Test the full create-then-read cycle for each one individually before trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference — categories and versions that actually work together
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dividends-income, pensions-income, foreign-income, other-income, insurance-policies-income, employments-income/other, deductions/other&lt;/strong&gt; — version 2.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expenses/other&lt;/strong&gt; — version 3.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;charges/pensions&lt;/strong&gt; — version 3.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;disclosures, state-benefits&lt;/strong&gt; — version 2.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reliefs/investment, reliefs/pensions, reliefs/foreign, reliefs/charitable-giving, reliefs/other&lt;/strong&gt; — version 3.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is criticism of HMRC's APIs — the documentation is genuinely more thorough than a lot of government API docs, and the sandbox stateful-testing model is a good idea when it works consistently. It's just that the path from "read the docs" to "working integration" has more hidden turns than the docs alone suggest, and I didn't find a single place that laid all of this out together. Hopefully this saves someone a few hours.&lt;/p&gt;

&lt;p&gt;If you're building against these APIs and hitting your own version of this, I'd genuinely like to compare notes — feel free to reach out.&lt;/p&gt;

</description>
      <category>api</category>
      <category>hmrc</category>
      <category>uk</category>
      <category>fintech</category>
    </item>
  </channel>
</rss>
