Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-token-becomes-a-body-field && read --section="top" 0%
Microservices

The Token Becomes a Body Field

Trill's gateway authenticates the caller, parses the subject claim out of the JWT and writes it into the request body as a userId - a Content Enricher at the edge that explains the whole estate's API shape, and carries four defects in fifty lines.

By Kumar Chandrachooda 13 Dec 2025 6 min read
A key dissolving into a document as an added field

Open any .rest file in the Trill estate and something looks wrong. Every write request posts a body containing a userId, and none of the downstream services ever reads an Authorization header for identity. Read only the services and you would conclude the estate is wide open — anybody can post a story as anybody, because the author is whatever the caller typed.

Read the gateway and it makes sense. Part 6 covered the middleware that publishes to the bus; the one registered immediately before it rewrites every request body on its way through.

Fifty lines that define the estate's API shape

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    var request = context.Request;
    if (!ValidMethods.Contains(request.Method))
    {
        await next(context);
        return;
    }

    if (!request.Headers.ContainsKey("authorization"))
    {
        await next(context);
        return;
    }

    var path = context.Request.Path.Value;
    if (path is not null && (path.Contains("sign-in") || path.Contains("sign-up")))
    {
        await next(context);
        return;
    }

    var authenticateResult = await context.AuthenticateAsync();
    if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
    {
        context.Response.StatusCode = 401;
        return;
    }

    string content;
    context.User = authenticateResult.Principal;
    using (var reader = new StreamReader(request.Body))
    {
        content = await reader.ReadToEndAsync();
    }

    var payload = JsonSerializer.Deserialize<Dictionary<string, object>>(content);
    if (payload is null)
    {
        await next(context);
        return;
    }

    payload["userId"] = Guid.Parse(context.User.Identity.Name);
    var json = JsonSerializer.Serialize(payload);
    await using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(json));
    context.Request.Body = memoryStream;
    context.Request.ContentLength = json.Length;
    await next(context);
}

Trill.APIGateway/src/Trill.APIGateway/Framework/UserMiddleware.cs:19-68, with ValidMethods being the set { "POST", "PUT", "PATCH" } declared on lines 14–17.

In enterprise-integration terms this is a Content Enricher: a component that augments a message with data the sender did not supply, taken from a source the sender could not have known. The source here is the validated JWT; the augmentation is a userId field written into the JSON body. The gateway is acting as a trusted subsystem — it converts a bearer token into a data field, and everything behind it treats that field as authoritative because only the gateway is supposed to be able to set it.

That is a real, defensible pattern, and it deserves to be said plainly before the criticism starts. It buys three things. Downstream services need no JWT library, no signing certificate and no claims parsing — they take a Guid in a command like any other parameter, which makes them trivially testable and keeps identity out of the domain layer. The asynchronous path gets identity for free: a message published to RabbitMQ has no Authorization header, so putting the user id in the payload is the only way it survives the transport hop at all. And a service can be exercised directly on port 5050 during development without minting a token, which is exactly what the .rest files do.

The pattern is load-bearing for the entire estate's API shape, and it is documented nowhere. No README says it, no comment in UserMiddleware says it, and no downstream service says why it trusts userId. A newcomer reading Stories in isolation sees a command that takes an unauthenticated caller-supplied identity, and they are not wrong — they are just missing the fifty lines that make it safe, in the one repository they had no reason to open.

Four defects in fifty lines

Now the fair half of the ledger. Every one of these I confirmed against the file above.

ContentLength = json.Length is a character count, not a byte count. Line 66. json is a string; json.Length counts UTF-16 code units. The body written to the stream is Encoding.UTF8.GetBytes(json). For pure ASCII those numbers coincide, which is why this survives every test written in English. Post a story titled Café and the byte length is one greater than the character length; post anything with an emoji and it is two or three greater. Kestrel honours Content-Length when the proxy forwards the request, so the downstream service receives a truncated body — usually a JSON parse failure, occasionally something worse, and never a message that says “your title had an accent in it”. The fix is Encoding.UTF8.GetByteCount(json), or better, write the bytes once and use memoryStream.Length.

The body is consumed and not restored on the null path. Lines 50–60. StreamReader reads request.Body to the end. If JsonSerializer.Deserialize<Dictionary<string, object>> then returns null — which it does for a body of literally null, and for an empty body under some options — the middleware calls next(context) without putting the stream back. Everything downstream sees an exhausted body at position zero-length. It is a narrow path, but it is the path taken by exactly the kind of malformed request you would want to fail cleanly.

Guid.Parse(context.User.Identity.Name) is unguarded. Line 62. Identity.Name is the subject claim of a token this gateway has just successfully validated, so in the happy case it is a GUID string minted by the Users service. But any token issued by any other authority that satisfies validIssuer: "trill" and carries a non-GUID subject throws FormatException here. And the gateway has no error-handling middleware at allStartup.Configure registers LogContextMiddleware, CORS, Convey, Jaeger, Prometheus, the access-token validator, authentication, RabbitMQ, these two middlewares, routing and authorisation, and nothing that catches. Outside development, that is an unhandled exception surfacing as a bare 500.

JsonSerializer.Deserialize<Dictionary<string, object>>(content) is unguarded too. Line 55. A JSON array body, a form post, a file upload or malformed JSON throws JsonException at the same altitude, with the same result. The middleware assumes every POST, PUT and PATCH through this gateway carries a JSON object, and the type system offers no help because Dictionary<string, object> will accept anything shaped like one and reject everything else at runtime.

None of the four is exotic. All four are the same class of omission: the middleware validates the token thoroughly and validates nothing else at all. The JWT path has a proper AuthenticateAsync, a Succeeded check, a null-principal check and a 401. The body path has none of that, and the body is the thing being rewritten.

The claims that ride the bus

There is a fifth issue, and it is the one I would fix first. Recall from part 5 that the messaging middleware attaches a correlationContext to every published AMQP message. Here is how that object is built:

User = new CorrelationContext.UserContext
{
    Id = context.User.Identity.Name,
    IsAuthenticated = context.User.Identity.IsAuthenticated,
    Role = context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role)?.Value,
    Claims = context.User.Claims.ToDictionary(c => c.Type, c => c.Value)
}

Framework/CorrelationContextBuilder.cs:21-27. That last line puts every claim on the principal into an object that is serialised into a message header and published to RabbitMQ, where it is persisted in a durable queue, read by consumers, and copied into their own correlation contexts as messages fan out.

Meanwhile, twenty lines of the same repository's appsettings.json are devoted to keeping exactly that sort of thing out of the logs:

"excludeProperties": [
  "api_key", "access_key", "ApiKey", "ApiSecret", "ClientId", "ClientSecret",
  "ConnectionString", "Password", "Email", "Login", "Secret", "Token"
]

appsettings.json:31-44, replicated identically in all eight service configurations.

A Content Filter is applied to the logging channel and not to the message channel. Whoever wrote the excludeProperties list understood the problem precisely — that is a thoughtful list, and Email being on it is the tell that somebody thought about personal data and not just credentials. The same person then serialised the full claims dictionary onto the bus, because the bus did not look like an output channel. It always is. Anything a message carries ends up in a broker's disk, a consumer's logs, a trace span and, eventually, a support ticket screenshot.

The rule I would write on the wall

If your edge rewrites requests — and gateways that do identity translation almost always end up rewriting requests — then the rewrite is a serialisation boundary, and every serialisation boundary needs the same three things: a byte-accurate length, a guarded parse, and an explicit allow-list of what crosses it. Trill's gateway has none of the three, and it works anyway, because in a demo the bodies are ASCII, the JSON is well-formed and nobody reads the headers.

That is what makes it worth reading. The pattern is right, the implementation is fifty lines, and the four things it gets wrong are the four things that only start mattering when someone else's traffic arrives.

Next, the reason none of the above is even reached on the estate's most interesting endpoint: the policy that was never applied.