The Security Inversion
DShop puts all its authorization at the gateway and none in the services behind it - then publishes every service port to the host. The compose file quietly defeats the entire security model.
DShop's security model is easy to state: the gateway checks your token, and the services trust the gateway. That is a real, common pattern — a hardened perimeter with a soft interior, the API-gateway-as-bouncer. It works exactly as long as the only way to reach a service is through the gateway. Read DShop's compose file and you discover the interior is not behind the gateway at all. Every service publishes its port straight to the host. The perimeter guards a building whose back doors all open onto the street, and the file that opens them is not the security code — it's the deployment config nobody thought of as security code.
All the auth is at the front
The gateway takes authorisation seriously. Its BaseController is decorated [JwtAuth], so every controller that inherits it demands a valid token by default. Admin-only routes escalate with a subclassed attribute:
public class AdminAuth : JwtAuthAttribute
{
public AdminAuth() : base("admin") { }
}
...applied exactly where you'd hope — approving an order, creating a product. Tokens are HS256 JWTs issued by the Identity service, validated at the gateway with issuer dshop-identity-service. From the front, this looks like a properly guarded API: anonymous callers bounce, authenticated callers get their own data, admins get the admin routes. The YAML gateway mirrors it with auth: {type: jwt, global: true} and per-route claims: {role: admin}. Both front doors are locked.
The services behind it have no lock at all
Now open a downstream service. Here is the Orders service's controller — the thing that returns anyone's order:
[Route("")]
public class OrdersController : BaseController
{
[HttpGet("orders/{orderId}")]
public async Task<ActionResult<OrderDetailsDto>> Get([FromRoute] GetOrder query)
=> Single(await QueryAsync(query));
}
No [Authorize]. No [JwtAuth]. Its BaseController is a plain ControllerBase with a dispatcher and nothing else — not a whiff of authentication. The service reads a query and returns the data. It has no idea whether the caller has a token, because it never checks. Every downstream service is like this: the authorisation lives entirely at the gateway, and the services are built on the assumption that the gateway is the only thing that can reach them.
That assumption is the whole security model. And it is an assumption about network topology, not about code — which means it can be broken by a file that contains no code.
The compose file that unlocks everything
Here is that file. Every service maps its internal port 5000 to a distinct host port:
orders-service:
image: devmentors/dshop.services.orders
ports:
- '5005:5000'
api is 5000:5000, customers 5001, identity 5002, notifications 5003, operations 5004, orders 5005, products 5006, signalr 5007. Every one of them is reachable directly from the host. So the sentence that was doing all the security work — “the only way to reach a service is through the gateway” — is false the moment this compose file runs. GET localhost:5005/orders/{someGuid} returns that order. No token. No gateway. No check anywhere in the path, because the only component that checks tokens is the one you just walked around.
The gateway authorises; the compose file publishes; the net authorisation is zero. You can read any customer's orders, browse any cart, enumerate products, by talking to the service ports directly. The bouncer is diligent and standing at a door nobody has to use.
The smaller inversions around it
Once the interior is exposed, the estate's other security shortcuts compound rather than merely coexist:
- One secret to forge the whole estate. The same 70-character HS256 signing key is committed, in plaintext, in the gateway's
appsettings.json, the monolith'sappsettings.json, and the Ntrada gateway'sntrada.yml— all sharing issuerdshop-identity-service. HS256 is symmetric: the key that validates a token is the key that signs one. Anyone who reads the repository (it is public) can mint a valid admin token for the estate. A committed symmetric key is not a leaked password you rotate; it is the master key to the front door, printed in the README's neighbourhood. (Series 2 reads the Identity service's otherwise-decent JWT flow next to this exact scar in Identity Done Mostly Right, Secrets Done Wrong.) - The operation status endpoint is anonymous. The gateway's
OperationsControlleris[AllowAnonymous], so anyone holding an operation GUID can poll its result without a token. The 202 contract turns the correlation id into a bearer capability — possession of the GUID is the authorisation. - Sign up as an admin. The onboarding
.restfile's very first request posts"role": "admin", and the Identity service honours the role the client asks for. The role dropdown is a self-service privilege escalation: you do not attack the admin boundary, you request to be on the other side of it, and you're granted it. - CORS invites everyone with credentials. Both gateways run
AllowAnyOrigin().AllowCredentials()— a combination the CORS spec forbids precisely because “any origin” plus “send credentials” is the shape of a cross-site credential leak.
To be fair, and the lesson
To be fair to DShop: it is a teaching estate, and publishing every port to the host is convenient for a learner poking at services with a REST client — you want to hit localhost:5005 directly to see what the orders service does. Gateway-enforced auth with trusted internal services is also a legitimate production pattern; plenty of real systems put a hard perimeter around services that genuinely cannot be reached except through it, using a private network the compose file here simply doesn't create. The design is not wrong in the abstract. It is wrong as deployed, and the gap between those two is the entire point.
The durable lesson is the one that catches teams with real hardening at the edge: a perimeter security model is a claim about your network, and your network is defined by config you don't file under "security." DShop's [JwtAuth] attributes are correct. Its downstream services are correctly trusting. The hole is a ports: list in a YAML file that a reviewer reading the auth code would never open. When the interior trusts the perimeter, the perimeter includes every line that can expose the interior — the compose file, the firewall rules, the service mesh policy. Guard the code and publish the ports, and you have guarded nothing.
Next, a service built to solve a real problem that the rest of the estate then declines to use: the push channel nobody plugged in.