01

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.

💡
The whole course in one sentence

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:

1
Your browser sends you to the login page

Not the app's own login — a separate, dedicated identity service called Keycloak. The app never sees your password.

2
You type your password into Keycloak

Keycloak verifies it and hands your browser a token: a signed digital badge.

3
Your browser shows that badge to get data

Every request to load employees, departments, anything — carries the badge so the server knows it's really you.

4
The server checks the badge, then checks your wristband

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.

🧩
Why split it up?

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.

🧭
Why a non-coder should care

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?

02

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.

🪪
Keycloak — the identity provider

vs-plat-identity · The doorman. Checks your password and issues the signed badge. The only place passwords live.

🖥️
Web client — the front-end you see

vs-plat-web-client · A Vue single-page app. Starts the login, holds your badge for the tab, asks the back-end for data.

🛂
Gateway — the border guard

vs-plat-gateway · The single front door to the back-end. Checks every badge and decides what gets forwarded inward.

🔁
STS — the badge exchange office

vs-plat-sts · The Security Token Service. Swaps one badge for another, stamped for a specific inner service. Holds the crown-jewel secrets.

🗂️
Authz — the records office

vs-plat-authz · The source of truth for who belongs to which organization and what they may do. Backed by a database.

⚙️
Business API — the actual app logic

vs-plat-app · Serves employees, departments, and the real data. Enforces a permission check on every endpoint.

📦
Shared auth library — the rulebook

vs-plat-packages · Reusable code every service imports so they all validate badges and check permissions identically.

🎬
Orchestrator — the director

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.

CODE
var authority = $"http://localhost:8080/realms/{realm}";
return resource
  .WithEnvironment("VSPlatformAuth__Authority", authority)
  .WithEnvironment("VSPlatformAuth__AdditionalValidIssuers__0", authority)
  .WithEnvironment("VSPlatformAuth__RequireHttpsMetadata", "false");
PLAIN ENGLISH

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.

🔑
One source of trust

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.)

Keycloak
Gateway
STS
Authz

Checks your password and issues the original badge

Drop here

The single front door that checks every badge before forwarding

Drop here

Swaps a badge for one stamped for a specific inner service

Drop here

The records office: who belongs to which org and may do what

Drop here

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?

03

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:

🖥️
Browser (you)
🪪
Keycloak
Click "Next Step" to begin
🧩
What's PKCE, and why the puzzle?

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:

CODE
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 }),
});
PLAIN ENGLISH

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):

CLAIMS (DECODED)
{
  "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
}
PLAIN ENGLISH

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.

🛡️
Why the badge lives only in memory

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?

04

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.

🎫
The cloakroom-ticket rule

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.

1
Validate the badge

Is it genuinely signed by Keycloak, unexpired, and stamped for the gateway?

2
Check one permission

Does this user hold gateway.access — the basic "allowed through the front door" right?

3
Swap the badge, then forward

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:

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);
  }
});
PLAIN ENGLISH

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?

05

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:

CODE
[HttpGet]
[RequireVsPermission(ApiPermissions.EmployeesRead)]
public async Task<ActionResult> GetAll()
{
  var items = await dbContext.Employees…ToListAsync();
  return Ok(items);
}
PLAIN ENGLISH

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.

🚪
Keycards, not a single master key

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.
🔎
Why this is a debugging superpower

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:

1
Ask "which orgs am I in?"

After login the app calls the authz service's /orgs/mine, which reads the records office.

2
You pick one

If you're in more than one, a picker appears. You choose, say, "Northeast Division."

3
Get a fresh badge for that org

The token is re-swapped with a vs-context scope, stamping a single org_id into it.

4
Every request now carries that org

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:

CODE
[HttpGet("mine")]
[RequireVsPermission(AuthzPermissions.OrgsRead)]
public async Task<ActionResult<IReadOnlyList<OrgSummaryDto>>> GetMine(…)
PLAIN ENGLISH

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.

The "I was granted access but it doesn't work yet" trap

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?

06

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:

🖥️
Browser
🪪
Keycloak
🛂
Gateway
🔁
STS
⚙️
API
Click "Next Step" to begin

A debugging field guide

When something fails, the symptom usually points straight at one stage of that journey. Here's where to look first:

401
"Unauthorized" everywhere

The badge is missing, expired, or unreadable. Look at login and token expiry — identity itself isn't being proven.

403
Logged in, but one action is denied

Identity is fine; a permission is missing. Check the grant in the authz records office and whether a fresh badge was issued.

🎯
Works at the gateway, fails inside

An audience mismatch — a service received a badge stamped for someone else. Check the token swap and each service's expected audience.

🏢
"OrganizationMismatch"

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.

🧠
The instinct to build

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:

1"VSPlatformAuth": {
2 "Authority": "http://localhost:8080/realms/vs-platform",
3 "Audience": "vs-platform-gateway",
4 "Sts": { "CallerId": "vs-platform-api" }
5}
💬
The fix in plain words

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?

🎓
That's the platform's auth, end to end

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.