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.
Why this exists
HMRC requires every call to their MTD APIs (VAT, ITSA, and others) to carry a set of Gov-Client-* and Gov-Vendor-* 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.
The gotcha nobody's docs make clear: server-side vs browser-side
This is the part that catches out backend-only implementations. A chunk of the required headers describe the end user's device and browser, not your server:
Gov-Client-Device-IDGov-Client-ScreensGov-Client-Window-SizeGov-Client-Browser-JS-User-AgentGov-Client-Timezone
If your architecture is a typical SPA-calls-API-calls-HMRC setup, these values have to be captured client-side, 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 Gov-Client-Device-ID 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.
The headers that genuinely are yours to generate server-side are the Gov-Vendor-* set — things like Gov-Vendor-Version, Gov-Vendor-Product-Name, Gov-Vendor-License-IDs — since those describe your software, not the user.
Structuring it in .NET
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 DelegatingHandler that assembles the full HMRC header set before the outbound HttpClient call.
public class HmrcFraudHeaderHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HmrcFraudHeaderHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var ctx = _httpContextAccessor.HttpContext;
// Client-supplied values, forwarded from the frontend
CopyHeaderIfPresent(ctx, request, "Gov-Client-Device-ID");
CopyHeaderIfPresent(ctx, request, "Gov-Client-Screens");
CopyHeaderIfPresent(ctx, request, "Gov-Client-Window-Size");
CopyHeaderIfPresent(ctx, request, "Gov-Client-Timezone");
// Server-generated values, describing your software
request.Headers.Add("Gov-Client-Connection-Method", "WEB_APP_VIA_SERVER");
request.Headers.Add("Gov-Vendor-Version", "yourapp=1.4.0");
request.Headers.Add("Gov-Vendor-Product-Name", "YourApp");
return base.SendAsync(request, cancellationToken);
}
private static void CopyHeaderIfPresent(
HttpContext? ctx, HttpRequestMessage request, string headerName)
{
if (ctx != null && ctx.Request.Headers.TryGetValue(headerName, out var value))
{
request.Headers.TryAddWithoutValidation(headerName, value.ToString());
}
}
}
The important design decision here is the split: your frontend collects device data once per session and sends it as ordinary headers to your 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.
What you actually see when it's wrong
HMRC doesn't always give you a clean 400. In sandbox testing, malformed or missing fraud prevention headers typically show up as:
- A
400with a vagueINVALID_HEADERorMISSING_HEADERcode but no indication of which header - Submissions that succeed but get flagged for manual review on HMRC's side (invisible to you at integration time)
- Inconsistent behavior between sandbox and production — sandbox is more forgiving
The practical fix is to test against HMRC's official Test Fraud Prevention Headers 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.
Checklist
- [ ] Are
Gov-Client-*device/browser headers actually coming from the client, not fabricated server-side? - [ ] Is
Gov-Client-Device-IDpersisted per-device (not regenerated every session)? - [ ] Are
Gov-Vendor-*headers static and versioned correctly with each release? - [ ] Have you run your header set through HMRC's validation endpoint, not just the live sandbox?
- [ ] Does your timezone header format match IANA format exactly (e.g.
Europe/London), not an offset?
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.
Top comments (0)