Identity Done Mostly Right, Secrets Done Wrong
The Identity service gets the hard parts of auth genuinely right - issue, refresh, revoke - and then commits its signing key to source control and ships a CORS policy the spec forbids. Part 13 of Nine Services and a Message Bus.
Authentication is the one service where “mostly right” is not a passing grade, and Identity is a case study in why. The hard, easy-to-get-wrong parts — token issuance, refresh, revocation — are done well. The easy, catastrophic-to-get-wrong parts — where the signing key lives, which origins may call with credentials — are done wrong. This part reads both halves, because the gap between them is the most useful thing in the service: it shows that getting the cryptography right buys you nothing if you commit the key.
The half that's genuinely good
Identity is the only service in the estate that takes HTTP in and only publishes events out — no bus handlers at all (Part 1). It owns the auth lifecycle, and the shape is sound. It issues HS256 JWTs, validates them, and — the part most homegrown auth skips — it can revoke them. Revocation is the hard problem with JWTs, because a JWT is valid by signature until it expires; you cannot un-issue one. Identity solves it the standard way, with a Redis deny-list: a revoked token's id goes into Redis with a TTL matching its remaining lifetime, and every validation checks the list. “Active” means “absent from the deny-list.”
I won't re-derive that mechanism here, because it lives in the shared framework and the companion series reads it line by line, including its per-request Redis round-trip and a subtle iat-in-milliseconds quirk — Revoking the Irrevocable. What matters for Identity-the-service is that it uses the mechanism correctly: issue, refresh, revoke, a complete and coherent lifecycle. On the domain axis, this is the service that most looks like someone who has built auth before. Which is exactly what makes the other half jarring.
Secret one: the key is in the repository
AddJwt() reads the signing secret from configuration, and configuration is appsettings.json, and appsettings.json is in git:
"jwt": {
"secretKey": "…committed to source control…",
"expiryMinutes": 30,
"issuer": "dshop-identity-service",
"validateLifetime": true
}
The HS256 secret — the single symmetric key that both signs and verifies every token in the system — is a literal string in a tracked file, and it is duplicated in appsettings.docker.json for good measure. I am deliberately not reproducing the value even though the repository is public and MIT-licensed; the value is not the lesson, the pattern is. HS256 is symmetric, so the signing key is also the forging key — anyone who can read this file can mint a valid token for any user. With the key in source control, the key is in every clone, every fork, every CI cache, every developer's laptop, and every layer of the Docker image forever, because a secret committed once is committed in history even after you delete it.
The estate is not unaware of secret management — Vault is wired into the boot sequence (UseVault() sits right there in Program.cs). But it is configured enabled: false, so the plumbing that exists to pull secrets from a vault at runtime is switched off, and the hardcoded fallback is what actually signs tokens. To be fair, this is a teaching repository and a committed dev key is a common shortcut for “clone and run”; the Vault path being present shows the authors knew the right answer. But the shortcut and the right answer sit in the same repo with the shortcut winning, and there is no comment marking the key as throwaway — nothing stops it being copied into a real deployment, which is exactly how dev keys become production keys.
Secret two: the claims that never arrive
Read the ClaimsProvider — the hook where a service enriches a token with roles and custom claims:
public async Task<IDictionary<string, string>> GetAsync(Guid userId)
{
// Provide your own claims collection if needed.
// return await Task.FromResult(new Dictionary<string, string> { ... });
return await Task.FromResult(new Dictionary<string, string>());
}
It returns an empty dictionary. Every token Identity issues carries the baseline subject and nothing else — no roles, no permissions, no custom claims — because the provider that would add them is a stub with the real implementation commented out. This is honest (it is obviously a placeholder) but consequential: any downstream authorization that wanted to check a claim has nothing to check. The tokens are authenticated but not meaningfully authorized; they say who you are and nothing about what you may do.
Secret three: a CORS policy the spec forbids
The CORS configuration reaches for every “allow” at once:
options.AddPolicy("CorsPolicy", cors =>
cors.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
AllowAnyOrigin() together with AllowCredentials() is not just permissive — it is a combination the CORS specification explicitly disallows, because it would let any website on the internet make credentialed requests (cookies, authorization headers) to the service on a visitor's behalf. Modern ASP.NET Core throws at runtime rather than honour it; the wildcard-with-credentials pairing is the browser-security equivalent of leaving the door open and the alarm off. A credentialed CORS policy that trusts any origin is not a loose setting; it is the deliberate defeat of the same-origin protection the browser exists to provide. Signalr carries the identical pairing, so the mistake is copied, not isolated.
The pattern behind the three
Line the three failures up and they share a shape: the security controls that require cryptographic correctness are done well, and the ones that require operational discipline are done badly. The token math is right; the key handling is wrong. The revocation logic is right; the claims are absent. The auth lifecycle is right; the CORS boundary is wide open. It is a very common real-world profile — teams that can implement HS256 correctly still commit the key, because the key is a deployment concern and deployment is where security most often leaks. DShop is a clean specimen of the split.
There is a bigger version of this story at the estate level, and it is worse than any single service: the downstream services trust the gateway to have done authorization and do almost none themselves, while the compose file publishes every service port to the host — so the whole careful auth story can be walked around rather than broken. That inversion is the companion estate series' The Security Inversion; from Identity's own vantage, the finding is contained: build the lock well, and it still opens if you tape the key to the frame.
The rule of thumb
- Getting the cryptography right earns you nothing if the key is in the repository. A symmetric signing key in
appsettings.jsonis a forging key in every clone and every image layer, forever. Secrets belong in a secret store the running process reads at startup — and the store has to be enabled, not merely wired. AllowAnyOriginandAllowCredentialsare mutually exclusive for a reason. If you find both in one policy, it is not a strict setting you loosened — it is a protection you switched off, and the framework will refuse it.- An empty claims provider means authenticated-but-not-authorized. A token that proves identity and carries no claims pushes every “may they?” decision somewhere else; make sure that somewhere exists.
Identity's tests, for what it's worth, reference the service code and contain not a single test. It is not alone. Next: The Test Folder That Lies — a coverage census across nine services, where one of them is the only one actually testing anything.