DEV Community

Virendra Vyas
Virendra Vyas

Posted on

What HMRC's Fraud Prevention Headers Actually Require (That the Docs Don't Tell You)

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-ID
  • Gov-Client-Screens
  • Gov-Client-Window-Size
  • Gov-Client-Browser-JS-User-Agent
  • Gov-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());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 400 with a vague INVALID_HEADER or MISSING_HEADER code 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-ID persisted 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)