The doorman & the badge
You click "Sign In" and land inside the app. Here's the hidden journey that makes that one click safe — and why it touches half a dozen separate programs.
Two questions, every single time
Every time you open a page in the VS Platform, the system quietly asks two completely different questions about you. Almost all of security comes down to keeping these two apart.
Who are you?
Proving your identity. This is authentication — the doorman checking your ID before you walk in.
What may you do?
Deciding your permissions. This is authorization — the wristband that opens some doors in the building but not others.
Authentication is the ID check at the door; authorization is the wristband that decides which rooms you can enter. Mix them up and you get security bugs — keep them separate and the whole system stays sane.
What actually happens when you click "Sign In"
You're looking at the VS Platform single-page app in your browser. You click Sign In. From your point of view you blink and you're in. Under the hood, this happened:
Not the app's own login — a separate, dedicated identity service called Keycloak. The app never sees your password.
Keycloak verifies it and hands your browser a token: a signed digital badge.
Every request to load employees, departments, anything — carries the badge so the server knows it's really you.
Is the badge genuine? (authentication) Are you allowed to see this? (authorization) Only then does data come back.
Why is this spread across so many programs?
Here's the surprise for a lot of people: this isn't one login function in one file. In the VS Platform, the work is split across eight separate code repositories, each a different program with one job.
Each part can be built, secured, and updated on its own. The one program that holds the most dangerous secrets stays tiny and isolated, so a bug somewhere else can't leak them. Engineers call this separation of concerns.
In the next module you'll meet all eight characters by name. For now, just hold this picture: a doorman (Keycloak) issues a badge (a token), and a border guard (the gateway) checks it before letting anything through.
When you ask an AI assistant to "add a page that shows payroll," knowing this split lets you say exactly where the rule lives: "the API should require a payroll permission." That's the difference between vague requests and instructions an AI can actually follow.
Quick check
A user logs in successfully, but when they open the Payroll page they get "access denied." Which of the two questions failed?
Where does your password get typed in?
Meet the actors
Eight repositories, each a small program with one job. Think of them as the cast of a heist movie — every member has a single specialty, and the plan only works when they cooperate.
The cast list
Each of these is a separate repository named vs-plat-*. Hover any term you don't recognize — there's a definition behind every dashed underline.
vs-plat-identity · The doorman. Checks your password and issues the signed badge. The only place passwords live.
vs-plat-web-client · A Vue single-page app. Starts the login, holds your badge for the tab, asks the back-end for data.
vs-plat-gateway · The single front door to the back-end. Checks every badge and decides what gets forwarded inward.
vs-plat-sts · The Security Token Service. Swaps one badge for another, stamped for a specific inner service. Holds the crown-jewel secrets.
vs-plat-authz · The source of truth for who belongs to which organization and what they may do. Backed by a database.
vs-plat-app · Serves employees, departments, and the real data. Enforces a permission check on every endpoint.
vs-plat-packages · Reusable code every service imports so they all validate badges and check permissions identically.
vs-plat-orchestrator · Boots all the others together with the right wiring for local development, using .NET Aspire.
The glue: everyone trusts the same doorman
For badges to mean anything, every service has to agree on who is allowed to issue them. The orchestrator guarantees this by stamping every service with the same issuer address at startup.
var authority = $"http://localhost:8080/realms/{realm}";
return resource
.WithEnvironment("VSPlatformAuth__Authority", authority)
.WithEnvironment("VSPlatformAuth__AdditionalValidIssuers__0", authority)
.WithEnvironment("VSPlatformAuth__RequireHttpsMetadata", "false");
Build the one trusted doorman's address (Keycloak, on the local machine).
Take each service being started up...
...and tell it: "this is the only issuer whose badges you trust."
List that same issuer as an accepted signer, so older and newer badges both validate.
Allow plain web addresses — true only for local development, never in production.
Because every service is handed the same issuer, a badge signed by Keycloak is accepted everywhere, and a forged badge is accepted nowhere. This single shared fact is what holds the whole platform together.
Match each actor to its job
Drag each character onto the role it plays. (On a phone, tap and drag.)
Checks your password and issues the original badge
The single front door that checks every badge before forwarding
Swaps a badge for one stamped for a specific inner service
The records office: who belongs to which org and may do what
Quick check
Why keep identity (Keycloak) in its own repo instead of building login into the business app?
What would break if two back-end services were configured to trust different issuers?
Getting in
How the password you type becomes a signed badge — without the app ever seeing the password. This is the authentication half of the story.
The redirect dance
The pattern the platform uses is an industry standard called OpenID Connect. The clever part is the Authorization Code flow: your password goes only to Keycloak, and the app receives a badge instead.
Step through the dance — click Next Step:
PKCE is like mailing yourself half of a torn ticket before you leave. Even if someone snatches the one-time code in transit, they can't redeem it without the matching half only your browser holds.
How the browser is configured for this
The front-end sets up an OIDC helper once, telling it where the doorman lives and what kind of badge to request. This is the real configuration from the web client:
export const userManager = new UserManager({
authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: 'code',
scope: 'openid profile email vs-context vs-platform-gateway',
stateStore: new WebStorageStateStore({ store: browserStorage }),
});
Create one login helper for the whole app.
authority is the doorman's address (Keycloak).
client_id names this app so Keycloak knows who's asking.
After login, send the browser back to this exact page.
'code' chooses the safe Authorization Code flow.
The scope lists what to pack into the badge: identity, email, org context, and front-door access.
Keep the login state in the browser tab's temporary storage.
What's actually inside the badge?
The badge is a JWT — three parts separated by dots. Anyone can read the middle part, but only Keycloak can produce a valid signature, so nobody can forge or edit it.
Header
Bookkeeping: what kind of token this is and which signing key was used.
Claims
The facts: who you are, who the badge is for, when it expires, your org. Readable, not secret.
Signature
Keycloak's tamper-proof seal. Change one letter of the claims and the seal no longer matches.
Decoded, the claims part of a real badge looks like this (sensitive values replaced with placeholders):
{
"iss": "http://localhost:8080/realms/vs-platform",
"aud": "vs-platform-gateway",
"sub": "<your-user-id>",
"preferred_username": "<your-username>",
"org_id": "<org-uuid>",
"exp": 1735689600
}
iss — who issued it: the one trusted doorman.
aud — who it's for: the front door (gateway). This matters a lot in the next module.
sub — the subject: a stable id for you.
Your human-readable username.
Which organization this session is acting inside.
exp — the expiry time. After this moment the badge is worthless.
The platform deliberately keeps tokens in the tab's memory and never in long-term browser storage. If a malicious script slipped onto the page (an XSS attack), there's no saved token sitting on disk for it to grab, and the short expiry means anything stolen dies quickly.
Quick check
A colleague worries the business app could leak passwords in its logs. Based on the login flow, what do you tell them?
Someone decodes their token, changes org_id to another company's id, and sends it back. What happens?
Crossing the gateway
Your browser holds a badge stamped "front door." But the inner services won't accept it. Here's the border crossing — and the badge-swap office that makes it work.
One badge can't open every door
Remember the aud (audience) claim from the last module? A badge stamped vs-platform-gateway is accepted only by the gateway. The business API rejects it on sight, because its name isn't on the stamp.
A coat-check ticket only works at the cloakroom that issued it. You can't wave it at the bar and expect a drink. Each VS Platform service is its own cloakroom — it only honours tickets stamped with its own name.
So how does a request actually reach the API? The gateway does three jobs, in order, for every single request.
Is it genuinely signed by Keycloak, unexpired, and stamped for the gateway?
Does this user hold gateway.access — the basic "allowed through the front door" right?
Trade the badge for one stamped for the target service, strip the original, and pass the request inward.
Listen in on the border crossing
Here's the conversation between the four characters when you ask for the employee list. Play it message by message:
Why bother with a separate exchange office?
The badge-swap is done by the STS, not the gateway itself. That's deliberate: the dangerous secrets needed to mint new badges live in one tiny service, so a bug anywhere else can't leak them.
RFC 8693
The official standard for "trade this token for a different one." Not a homegrown trick.
caller key
A shared secret the gateway shows the STS to prove it's an allowed caller — not just anyone can request a swap.
Redis cache
Swapped badges are remembered briefly, so repeat requests skip the round-trip to Keycloak.
And critically — the gateway throws away the browser's original badge before forwarding. The inner service only ever sees the freshly-stamped one. Here's that exact moment in the gateway code:
transformContext.AddRequestTransform(requestContext =>
{
requestContext.ProxyRequest.Headers.Authorization = null;
if (requestContext.HttpContext.Items[ExchangedTokenItemKey] is string accessToken)
{
requestContext.ProxyRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
}
});
Just before forwarding the request inward…
…first, rip off the original front-door badge completely.
Then, if the badge-swap produced a new API-stamped badge…
…attach that one instead.
Net effect: the inner service never sees the browser's original badge — only the one meant for it.
Quick check
Why can't the gateway simply pass the browser's badge straight through to the API unchanged?
You're designing a similar system. What's the security argument for one STS instead of letting each service swap its own tokens?
Who-can-do-what
The badge got you in the building. Now every door has its own keycard reader. This is authorization — and it's where multiple organizations, a records office, and a locksmith all come in.
Every endpoint guards itself
Inside the business API, individual actions are protected one at a time. Reading the employee list requires the employees.read permission. The check is a single line above the method:
[HttpGet]
[RequireVsPermission(ApiPermissions.EmployeesRead)]
public async Task<ActionResult> GetAll()
{
var items = await dbContext.Employees…ToListAsync();
return Ok(items);
}
This method answers web requests for the employee list…
…but first, the caller must hold the employees.read permission. No permission, no entry.
Only if that passes does the method actually run.
Fetch the employees from the database…
…and send them back.
Being inside the building (authenticated) is not the same as being allowed into the payroll room. Each door has its own reader checking for one specific permission. Engineers call this least privilege.
The verdict is returned, never thrown
Behind that one-line check is a decision engine shared by every service (from the common rulebook library). It returns a verdict object explaining exactly why access was allowed or denied — which is gold when you're debugging.
These are the real reasons it can say "no":
UnauthenticatedNo valid badge: missing, unreadable, or expired. → becomes a 401.MissingPermissionValid badge, but the needed permission isn't granted. → becomes a 403.OrganizationMismatchThe badge's org doesn't match the org in the request. → 403.StaleAuthorizationPermissions changed after the badge was issued — it's out of date.When a request fails, the system doesn't just say "denied." It names the reason. So instead of guessing, you can tell an AI assistant precisely: "the API returns OrganizationMismatch — the request org and the token org disagree." That's a fixable bug report, not a mystery.
One person, many organizations
A single user can belong to several organizations. But a badge carries exactly one org_id at a time — you act inside one org, then switch. Here's the choreography:
After login the app calls the authz service's /orgs/mine, which reads the records office.
If you're in more than one, a picker appears. You choose, say, "Northeast Division."
The token is re-swapped with a vs-context scope, stamping a single org_id into it.
The API checks the request's org matches the badge's org — that's the OrganizationMismatch guard from the last screen.
The list of organizations comes from the authz service, which guards it with its own permission:
[HttpGet("mine")]
[RequireVsPermission(AuthzPermissions.OrgsRead)]
public async Task<ActionResult<IReadOnlyList<OrgSummaryDto>>> GetMine(…)
Answer the question "which orgs do I belong to?"…
…but only for callers holding authz.orgs.read.
Return the list of organizations this user can act inside.
The records office and the locksmith
Where do permissions actually live? Two places, kept in sync — and understanding this split explains a whole class of "why can't I do this?" bugs.
The records office (Postgres)
The authz service's database is the source of truth: who is in which org, and exactly what they may do. Change it here and it's official.
The locksmith (reconciler)
A job called the reconciler copies those official grants into Keycloak, so they get baked into future badges.
If someone is given a new permission in the records office but the locksmith hasn't reprogrammed Keycloak — or the user is still carrying an old badge — their access won't take effect until a fresh badge is issued. That gap is the StaleAuthorization reason in disguise.
Quick check
A user gets a 403 on the payroll page but a 401 on a different page. What's the difference?
You add a permission for a user in the authz records office, but they still get denied. Most likely cause?
A user in two orgs is acting inside Org A, but a request targets Org B's data. What stops them?
When it breaks — and the big picture
You've met every actor. Now watch one click travel the entire system end to end, then build the instinct to debug it when something goes wrong.
The whole journey, one click
Everything from the last five modules, in a single trip. Step through it — by the end this should feel familiar, not mysterious:
A debugging field guide
When something fails, the symptom usually points straight at one stage of that journey. Here's where to look first:
The badge is missing, expired, or unreadable. Look at login and token expiry — identity itself isn't being proven.
Identity is fine; a permission is missing. Check the grant in the authz records office and whether a fresh badge was issued.
An audience mismatch — a service received a badge stamped for someone else. Check the token swap and each service's expected audience.
The request targets a different org than the badge carries. The user likely needs to switch orgs (re-pick) to get a badge for the right one.
Don't ask "is auth broken?" Ask "which stage of the journey broke?" Login, badge validation, the swap, the permission check, or the org match. Naming the stage turns a vague panic into a one-line bug report an AI can act on.
Spot the bug
Here's a snippet of the business API's configuration. One line will make every request fail after it passes the gateway. Click the line you think is wrong:
"VSPlatformAuth": {
"Authority": "http://localhost:8080/realms/vs-platform",
"Audience": "vs-platform-gateway",
"Sts": { "CallerId": "vs-platform-api" }
}
The business API must expect badges stamped vs-platform-api — its own name — not vs-platform-gateway. With the wrong audience, the gateway's freshly-swapped badge gets rejected, and every inner request fails with a 401 even though login worked perfectly.
You can now read the whole system
Six ideas, and you have the complete map:
Identity is separate
Keycloak alone handles passwords and issues signed badges.
Badges are stamped
The audience claim means a badge only opens the door it was made for.
One front door
The gateway validates, then swaps badges via the STS before forwarding.
Secrets stay isolated
Only the STS can mint badges, so the crown jewels live in one place.
Permissions per door
Each action checks one permission; orgs wall tenants apart.
Records, then locksmith
Postgres is the truth; the reconciler bakes it into future badges.
Final check
A user logs in fine, the dashboard loads, but one specific report returns an error. Where do you look first?
Why can each inner service stay relatively simple about security?
You can now trace a login from the click to the data and back, name every actor, and pinpoint where a failure lives. That's exactly the fluency you need to steer an AI assistant through this codebase with confidence.