Build a Four-Container Auth Stack

Build a local Angular, FastAPI, Keycloak, and PostgreSQL authentication stack.

Introduction

30 Second Summary

A sign-in screen can look convincing while the service behind it has no proof of who sent the request. Real protection starts when that service checks the identity evidence for itself.

In this project, you will launch a local authentication dashboard backed by four coordinated containers. You'll sign in through Keycloak before FastAPI validates the access token.

What You'll Build

At http://localhost:4200, you'll sign in to watch the protected API change from an anonymous 401 rejection to an authenticated 200 response containing your token subject.

By the end of this project, you'll have:

  • A four-container dashboard that starts with one Docker Compose command. The Angular interface calls FastAPI's public endpoint to display a successful response.
  • A persistent identity stack that waits for PostgreSQL to become healthy before Keycloak starts. You can recreate containers without losing realms, clients, or users because a named volume keeps the database files.
  • A browser sign-in flow using OpenID Connect with PKCE. The dashboard moves from anonymous 401 to token-present 501 to authenticated 200. The final response includes the token subject after JSON Web Token validation.
  • Secret Mission: Create the stack-user realm role to reveal a role-aware panel with keycloak.hasRealmRole().

Do I need any experience before starting?

You need a Windows computer with Docker Desktop running in Linux-container mode. The walkthrough assumes Visual Studio Code, Git Bash, Git, Node.js, and Python are already installed.

Application dependencies stay inside containers, so no extra host installation is required.

Before We Start

You are building a local four-container authentication stack with an Angular frontend, a FastAPI backend, a Keycloak identity provider, and a PostgreSQL database. Before the hands-on work begins, commit to one security rule: FastAPI independently validates each bearer token's signature and claims because the browser is not a trusted enforcement boundary.

Set Up the Workspace

A four-service stack needs one predictable Windows project root. That root anchors every relative path.

Before any application files exist, you need proof that Docker Desktop's Linux container engine is running. You also need proof that Docker Compose is available from Git Bash.

In this step, get ready to:
  • Run Docker Desktop in Linux-container mode.
  • Verify Docker Compose from Git Bash.
  • Prepare the empty workspace in Visual Studio Code.
Start Docker Desktop and verify Compose
  • Press the Windows key to open Windows search.
  • Type Docker Desktop into the search field.
  • Press Enter to launch Docker Desktop.
  • Wait until the Docker Desktop dashboard confirms that the engine is running.
  • Check that the dashboard identifies the active engine as Linux containers.
  • Use Docker Desktop's engine switcher to select Linux containers if needed.

Why Linux-container mode?

The images in this project run as Linux containers. This engine matches those images.

  • Press the Windows key to open Windows search.
  • Type Git Bash into the search field.
  • Press Enter to launch Git Bash.

Before you run the check, consider what version information Git Bash should reveal.

  • Verify that Docker Compose is available by running this command:
docker compose version

What Does This Command Prove?

The command asks the Compose plugin for version information. A version response confirms that Git Bash can reach Compose.

That is your first environment check complete. You will see Docker Compose version information in Git Bash.

No Compose Version Information?

Confirm that Docker Desktop still shows a running Linux engine. Close Git Bash after Docker Desktop is ready.

Reopen Git Bash before repeating the version check.

Help me diagnose why Docker Compose is unavailable in Git Bash on Windows.

Create the workspace folders

Compose resolves relative paths from the folder that contains compose.yaml. Creating that folder on your Desktop gives every later command a stable starting point.

  • Move to your Desktop by running this command:
cd ~/Desktop

Why Start Here?

Starting from the Desktop puts four-container-auth-stack in a predictable place. You can return to the same project root throughout the build.

  • Create the project directory structure by running these commands:
mkdir -p four-container-auth-stack/frontend/src/app four-container-auth-stack/backend/app
cd four-container-auth-stack

What Do These Commands Create?

  • The -p option creates every missing parent directory in each path.
  • The cd command makes four-container-auth-stack your current directory.

Before you inspect the workspace, picture the nested folders Git Bash should reveal.

  • Inspect the directory tree by running this command:
ls -R

What Does This Check Show?

The -R option lists the current directory recursively. This exposes each nested folder without creating application files.

You will see frontend/src/app in the recursive listing. You will also see backend/app.

Neither branch lists application files. That confirms the workspace is still an empty skeleton.

Missing a Workspace Folder?

Return to ~/Desktop before repeating the folder-creation block. Check each directory name for typing differences.

Help me fix a missing folder in my authentication stack workspace.

Open the project root in Visual Studio Code

Visual Studio Code treats the folder you open as a workspace. Opening four-container-auth-stack keeps future commands anchored to the folder that will contain compose.yaml.

Visual Studio Code may show a Workspace Trust prompt the first time it opens this folder.

Before you launch the editor, picture which folder should appear at the top of Explorer.

  • Open the current project root in Visual Studio Code by running this command:
code .

What Does This Command Open?

The code command launches Visual Studio Code. The . value tells it to open the current directory.

  • Select Yes, I trust the authors if the Workspace Trust prompt appears.
  • Confirm that Explorer shows four-container-auth-stack as the top-level folder.
  • Expand frontend in the Explorer sidebar.
  • Confirm that src/app appears with no files.
  • Expand backend in the Explorer sidebar.
  • Confirm that app appears with no files.

Your Explorer now shows the project root that will hold compose.yaml. Both application branches are ready for their container files.

Visual Studio Code Did Not Open?

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to launch it.
  • Select File from the top menu.
  • Select Open Folder.
  • Choose the four-container-auth-stack folder from your Desktop.

Help me open my Git Bash project folder in Visual Studio Code.

Your Linux container engine is running. Next up, you will launch all four services from this project root.

Launch the Four-Container Stack

Your workspace is ready. Every file now has a predictable home inside four-container-auth-stack.

This launch tests whether Angular, FastAPI, Keycloak, and PostgreSQL can coexist through Docker Compose. A public request proves connectivity. A protected request reveals how the unfinished authentication boundary behaves.

In this step, get ready to:
  • Define four coordinated Compose services with readiness checks and persistent storage.
  • Create the containerized Angular dashboard and FastAPI application.
  • Launch the stack and test its public and protected API boundaries.
Define the four Compose services

A Compose file describes each container as a service. Its health check controls when PostgreSQL is ready. Its named volume preserves Keycloak's database files across container recreation.

  • Use the VS Code file tree to create compose.yaml beside the existing frontend and backend folders.
  • Define the PostgreSQL service by pasting this first section:
services:
  postgres:
    image: postgres:18.6-bookworm
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: local-dev-only
    volumes:
      - keycloak_postgres_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 10s
      retries: 5
      start_period: 30s

What does this configuration do?

  • The postgres service runs PostgreSQL 18.6 with the local database credentials used by Keycloak.
  • The health check asks PostgreSQL whether the keycloak database is ready to accept connections.
  • The volume mount stores PostgreSQL 18 data beneath /var/lib/postgresql.
  • Save compose.yaml.
  • Confirm the file tree now lists compose.yaml beside both application folders.

YAML indentation looks uneven?

Keep postgres two spaces beneath services. Keep its settings four spaces beneath postgres.

Ask for help with checking the PostgreSQL service indentation.

  • Add the Keycloak service below the PostgreSQL health check by pasting:
  keycloak:
    image: quay.io/keycloak/keycloak:26.8.0
    command: ["start-dev"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: local-dev-only
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: local-dev-only
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      postgres:
        condition: service_healthy

How does Keycloak reach PostgreSQL?

  • The hostname postgres uses Compose service-name DNS to reach the database container.
  • The service_healthy condition delays Keycloak until PostgreSQL passes its health check.
  • The loopback binding exposes Keycloak on port 8080 only through this computer.
  • Save compose.yaml.
  • Confirm the file now contains both postgres and keycloak at the same indentation level.
  • Add the application services and volume declaration below the Keycloak dependency by pasting:
  backend:
    build:
      context: ./backend
    environment:
      KEYCLOAK_JWKS_URL: http://keycloak:8080/realms/stack-lab/protocol/openid-connect/certs
      KEYCLOAK_ISSUER: http://localhost:8080/realms/stack-lab
      KEYCLOAK_AUDIENCE: fastapi-api
      FRONTEND_ORIGIN: http://localhost:4200
    ports:
      - "127.0.0.1:8000:8000"

  frontend:
    build:
      context: ./frontend
    ports:
      - "127.0.0.1:4200:4200"

volumes:
  keycloak_postgres_data:

Why are there two Keycloak addresses?

The backend container reaches Keycloak through the service hostname keycloak. Browser-issued tokens identify their issuer through localhost.

The backend receives both values now. Token validation uses them in a later step.

  • Save compose.yaml.
  • Confirm the file contains exactly four service names: postgres, keycloak, backend, and frontend.

Compose file structure unclear?

Keep every service beneath services. Keep the final volumes declaration aligned with services.

Ask for help with checking the complete Compose hierarchy.

Build the API and dashboard images

Each application needs a container recipe plus the files it runs. The API exposes one public route and one protected route. The dashboard gives you controls for testing both routes.

  • Use the VS Code file tree to create backend/Dockerfile inside backend.
  • Define the API image by pasting:
FROM python:3.14.8-slim-bookworm
WORKDIR /code
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]

What does this image do?

  • The image installs the backend dependencies before copying the application package.
  • The final command starts FastAPI from app/main.py on port 8000.
  • Save backend/Dockerfile.
  • Confirm the file tree lists Dockerfile directly inside backend.
  • Use the new-file control beside backend to create requirements.txt.
  • Pin the intermediate backend dependency by pasting:
fastapi[standard-no-fastapi-cloud-cli]==0.143.0

Why pin the dependency?

The version pin keeps the container build reproducible. Every learner builds against FastAPI 0.143.0.

  • Save backend/requirements.txt.
  • Confirm the file contains one dependency line.
  • Use the new-file control beside backend/app to create __init__.py.
  • Leave backend/app/__init__.py empty.
  • Save backend/app/__init__.py.
  • Use the new-file control beside backend/app to create main.py.
  • Add the API configuration and public route by pasting:
import os
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

FRONTEND_ORIGIN = os.environ["FRONTEND_ORIGIN"]

app = FastAPI(title="Four-Container Authentication API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[FRONTEND_ORIGIN],
    allow_credentials=False,
    allow_methods=["GET"],
    allow_headers=["Authorization", "Content-Type"],
)
bearer = HTTPBearer()


@app.get("/health")
def read_health() -> dict[str, str]:
    return {"status": "ok", "service": "fastapi"}

What does this API code do?

  • The CORS middleware permits the dashboard origin to send GET requests to the API.
  • The /health route returns a small response that proves FastAPI is reachable.
  • The HTTPBearer object extracts bearer credentials for protected routes.
  • Add the temporary protected route beneath read_health() by pasting:


@app.get("/private")
def read_private(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> None:
    raise HTTPException(
        status_code=status.HTTP_501_NOT_IMPLEMENTED,
        detail="Token validation is not configured yet",
    )

Why return a temporary response?

The bearer dependency checks for an authorization header before the route runs. A request containing bearer credentials reaches the temporary 501 response because cryptographic token validation belongs to a later step.

  • Save backend/app/main.py.
  • Confirm backend now contains Dockerfile, requirements.txt, and the app folder.

Backend files in the wrong folder?

Keep Dockerfile and requirements.txt directly inside backend. Keep both Python files inside backend/app.

Ask for help with checking the backend file tree.

  • Use the new-file control beside frontend to create Dockerfile.
  • Define the Angular development image by pasting:
FROM node:24.21.0-bookworm
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 4200
CMD ["npm", "start"]

What does this image do?

The image installs the packages from package.json before copying the source. Its final command starts the Angular development server on port 4200.

  • Save frontend/Dockerfile.
  • Confirm the file tree lists Dockerfile directly inside frontend.
  • Use the new-file control beside frontend to create package.json.
  • Define the Angular packages and start script by pasting:
{
  "name": "four-container-auth-frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": { "start": "ng serve --host 0.0.0.0" },
  "dependencies": {
    "@angular/common": "22.2.2",
    "@angular/compiler": "22.2.2",
    "@angular/core": "22.2.2",
    "@angular/platform-browser": "22.2.2",
    "keycloak-js": "26.2.4",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.16.3"
  },
  "devDependencies": {
    "@angular/build": "22.2.2",
    "@angular/cli": "22.2.2",
    "@angular/compiler-cli": "22.2.2",
    "typescript": "6.0.3"
  }
}

What does this manifest control?

The dependencies provide Angular 22.2.2 plus the Keycloak browser adapter. The start script binds the development server to every interface inside its container.

  • Save frontend/package.json.
  • Confirm the manifest contains separate dependency and development-dependency sections.

The Angular workspace configuration is longer than one typing chunk. Paste both consecutive snippets before saving the file.

  • Use the new-file control beside frontend to create angular.json.
  • Add the workspace and build configuration by pasting this first part:
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "frontend": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "polyfills": ["zone.js"],
            "tsConfig": "tsconfig.app.json",
            "assets": [],
            "styles": ["src/styles.css"]
          },

What does the build section define?

The build section points Angular at src/main.ts, tsconfig.app.json, and src/styles.css.

  • Complete frontend/angular.json by pasting this second part immediately after the first:
          "configurations": {
            "production": { "outputHashing": "all" },
            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": { "buildTarget": "frontend:build:production" },
            "development": { "buildTarget": "frontend:build:development" }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

What does the serve section define?

The serve target connects the Angular development server to the matching build configuration. The default development target keeps source maps enabled.

  • Save frontend/angular.json.
  • Confirm the file begins with the schema entry and ends with three closing braces.
  • Use the new-file control beside frontend to create tsconfig.json.
  • Add the strict TypeScript configuration by pasting:
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "bundler",
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "lib": ["ES2022", "dom"]
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

Why use strict TypeScript settings?

Strict checks surface unsafe values during the container build. The bundler resolution mode matches Angular's application builder.

  • Save frontend/tsconfig.json.
  • Confirm the file defines both compiler and Angular compiler options.
  • Use the new-file control beside frontend to create tsconfig.app.json.
  • Point the application compiler at the bootstrap file by pasting:
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "outDir": "./out-tsc/app", "types": [] },
  "files": ["src/main.ts"],
  "include": ["src/**/*.d.ts"]
}

What does this application config do?

This file extends the shared TypeScript rules. It starts compilation from src/main.ts.

  • Save frontend/tsconfig.app.json.
  • Confirm the file references src/main.ts.
  • Use the new-file control beside frontend/src to create index.html.
  • Create the browser host document by pasting:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Four-Container Authentication Stack</title>
    <base href="/">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body><app-root></app-root></body>
</html>

What does the host document provide?

The browser loads Angular into the app-root element. The viewport metadata keeps the dashboard responsive.

  • Save frontend/src/index.html.
  • Confirm the document body contains <app-root></app-root>.
  • Use the new-file control beside frontend/src to create main.ts.
  • Create the Angular bootstrap entry point by pasting:
import 'zone.js';
import 'zone.js/plugins/zone-patch-fetch';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
bootstrapApplication(App).catch((error: unknown) => console.error(error));

What does the bootstrap file do?

The Zone.js imports connect asynchronous browser work to Angular updates. bootstrapApplication() starts the root App component.

  • Save frontend/src/main.ts.
  • Confirm the final line bootstraps App.
  • Use the new-file control beside frontend/src to create styles.css.
  • Add the global dark theme by pasting:
html { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button { font: inherit; }

What does the global stylesheet control?

These rules establish the page colors and system font stack. They also remove the default body margin.

  • Save frontend/src/styles.css.
  • Confirm the stylesheet contains rules for html, body, and button.

The root component coordinates login state and API calls. Paste all three consecutive snippets before saving frontend/src/app/app.ts.

  • Use the new-file control beside frontend/src/app to create app.ts.
  • Add the component metadata and dashboard state by pasting this first part:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import Keycloak from 'keycloak-js';

@Component({
  selector: 'app-root',
  changeDetection: ChangeDetectionStrategy.Eager,
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  private readonly keycloak = new Keycloak({
    url: 'http://localhost:8080',
    realm: 'stack-lab',
    clientId: 'angular-spa'
  });
  authenticated = false;
  subject = 'anonymous';
  authStatus = 'Initializing Keycloak';
  publicResult = 'Not called yet';
  privateResult = 'Not called yet';

  constructor() { void this.initializeAuthentication(); }
  async login(): Promise<void> { await this.keycloak.login({ redirectUri: window.location.origin }); }
  async logout(): Promise<void> { await this.keycloak.logout({ redirectUri: window.location.origin }); }
  async callPublicApi(): Promise<void> { this.publicResult = await this.callApi('http://localhost:8000/health'); }

What does the component state represent?

  • The Keycloak adapter points at the local stack-lab realm and angular-spa client.
  • The public fields hold the authentication status and API results shown by the template.
  • Add the protected API request method immediately after callPublicApi() by pasting:

  async callPrivateApi(): Promise<void> {
    const headers: Record<string, string> = {};
    if (this.keycloak.authenticated && this.keycloak.token) {
      await this.keycloak.updateToken(30);
      headers['Authorization'] = `Bearer ${this.keycloak.token}`;
    }
    this.privateResult = await this.callApi('http://localhost:8000/private', headers);
  }

How does the protected request change after login?

An anonymous call sends no authorization header. An authenticated call refreshes the in-memory token before adding it as a bearer credential.

  • Complete the component class by pasting this final part immediately after callPrivateApi().

  private async initializeAuthentication(): Promise<void> {
    try {
      this.authenticated = await this.keycloak.init({
        pkceMethod: 'S256',
        scope: 'fastapi-audience',
        checkLoginIframe: false
      });
      this.subject = this.keycloak.subject ?? 'anonymous';
      this.authStatus = this.authenticated ? 'Authenticated through Keycloak' : 'Anonymous session';
    } catch (error: unknown) {
      this.authStatus = `Keycloak is not configured yet: ${this.messageFrom(error)}`;
    }
  }

  private async callApi(url: string, headers: Record<string, string> = {}): Promise<string> {
    try {
      const response = await fetch(url, { headers });
      const body: unknown = await response.json();
      return `${response.status} ${response.statusText}\n${JSON.stringify(body, null, 2)}`;
    } catch (error: unknown) {
      return `Request failed: ${this.messageFrom(error)}`;
    }
  }

  private messageFrom(error: unknown): string {
    return error instanceof Error ? error.message : String(error);
  }
}

How does initialization behave?

  • Keycloak initialization requests PKCE with the optional fastapi-audience scope.
  • The API helper displays the HTTP status plus the parsed JSON response.
  • The error helper converts unknown failures into readable dashboard text.
  • Save frontend/src/app/app.ts.
  • Confirm the class ends with one closing brace after messageFrom().
  • Use the new-file control beside frontend/src/app to create app.html.
  • Create the dashboard template by pasting:
<main class="shell">
  <header>
    <p class="eyebrow">Docker Compose identity lab</p>
    <h1>Four-Container Authentication Stack</h1>
    <p>Angular, FastAPI, Keycloak, and PostgreSQL are running as separate services.</p>
  </header>
  <section class="status-grid">
    <article><span>Authentication</span><strong>{{ authStatus }}</strong></article>
    <article><span>Token subject</span><strong>{{ subject }}</strong></article>
  </section>
  <section class="actions">
    <button type="button" (click)="callPublicApi()">Call public API</button>
    <button type="button" (click)="callPrivateApi()">Call protected API</button>
    <button type="button" (click)="login()" [disabled]="authenticated">Sign in</button>
    <button type="button" (click)="logout()" [disabled]="!authenticated">Sign out</button>
  </section>
  <section class="results">
    <article><h2>Public response</h2><pre>{{ publicResult }}</pre></article>
    <article><h2>Protected response</h2><pre>{{ privateResult }}</pre></article>
  </section>
</main>

What can you test from the dashboard?

The status cards display the current identity state. The four controls trigger public calls, protected calls, sign-in, and sign-out.

  • Save frontend/src/app/app.html.
  • Confirm the template contains separate public and protected response panels.
  • Use the new-file control beside frontend/src/app to create app.css.
  • Style the dashboard by pasting:
:host { display: block; }
.shell { width: min(960px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0; }
header, article, .actions { border: 1px solid #334155; border-radius: 1rem; background: #111827; padding: 1.25rem; }
.eyebrow, article span { color: #38bdf8; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.78rem; }
.status-grid, .results { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; margin-top: 1rem; }
article strong { display: block; margin-top: 0.5rem; overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
button { border: 0; border-radius: 0.65rem; padding: 0.75rem 1rem; background: #0284c7; color: white; cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.45; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: #cbd5e1; }

What does the component stylesheet control?

The grid adapts the status and response cards to the available width. The wrapped preformatted text keeps API responses inside their cards.

  • Save frontend/src/app/app.css.
  • Confirm frontend/src/app now contains app.ts, app.html, and app.css.

Frontend file tree differs?

Keep the workspace configuration files directly inside frontend. Keep index.html, main.ts, and styles.css inside frontend/src.

Ask for help with checking the Angular file locations.

Start and test the stack

The first build downloads container images and installs application packages. It can sit quietly during those downloads, so give Docker time to finish.

Before you run the launch command, how many long-running services do you expect Compose to start?

  • Build the application images and start the four services by running:
docker compose up --build --detach --wait

What does this command do?

  • The build option creates the frontend and backend images from their Dockerfiles.
  • Detached mode leaves the services running after the command returns.
  • The wait option holds the command until services are running or healthy.

That is the first major boundary crossed. Your frontend, API, identity provider, and database are now running together.

Before you inspect the stack, which service do you expect to show a health status?

  • Inspect the four running services by running:
docker compose ps

You should see frontend, backend, keycloak, and postgres running. PostgreSQL should also show that its health check passed.

One of the four services missing?

Check that each service name sits beneath services in compose.yaml. A build failure usually points to a missing file or a file saved in the wrong folder.

Ask for help with diagnosing the failed Compose service.

The Public response panel should show a successful response containing {"status":"ok","service":"fastapi"}.

Before you test the protected route, do you think an anonymous browser request can cross the API's bearer-token boundary?

  • Click Call protected API.

The Protected response panel shows 401 Unauthorized. This shortfall is intentional. The API has rejected the anonymous request before the temporary route handler can run.

What did the two calls prove?

The public result proves that the browser can reach FastAPI through the loopback port. The protected result proves that HTTPBearer enforces the presence of bearer credentials.

The next step configures the identity provider that can issue those credentials.

Your four-container runtime is live. Next up, you will configure Keycloak so the dashboard can sign in and send a real access token.

✔️ Awesome, I've got everything!

Great. Double-check that every file is saved before you configure Keycloak.

ⓧ I'd like to double check the full code

services:
  postgres:
    image: postgres:18.6-bookworm
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: local-dev-only
    volumes:
      - keycloak_postgres_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 10s
      retries: 5
      start_period: 30s

  keycloak:
    image: quay.io/keycloak/keycloak:26.8.0
    command: ["start-dev"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: local-dev-only
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: local-dev-only
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      postgres:
        condition: service_healthy

  backend:
    build:
      context: ./backend
    environment:
      KEYCLOAK_JWKS_URL: http://keycloak:8080/realms/stack-lab/protocol/openid-connect/certs
      KEYCLOAK_ISSUER: http://localhost:8080/realms/stack-lab
      KEYCLOAK_AUDIENCE: fastapi-api
      FRONTEND_ORIGIN: http://localhost:4200
    ports:
      - "127.0.0.1:8000:8000"

  frontend:
    build:
      context: ./frontend
    ports:
      - "127.0.0.1:4200:4200"

volumes:
  keycloak_postgres_data:
FROM python:3.14.8-slim-bookworm
WORKDIR /code
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
fastapi[standard-no-fastapi-cloud-cli]==0.143.0

The file backend/app/__init__.py should be empty.

import os
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

FRONTEND_ORIGIN = os.environ["FRONTEND_ORIGIN"]

app = FastAPI(title="Four-Container Authentication API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[FRONTEND_ORIGIN],
    allow_credentials=False,
    allow_methods=["GET"],
    allow_headers=["Authorization", "Content-Type"],
)
bearer = HTTPBearer()


@app.get("/health")
def read_health() -> dict[str, str]:
    return {"status": "ok", "service": "fastapi"}


@app.get("/private")
def read_private(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> None:
    raise HTTPException(
        status_code=status.HTTP_501_NOT_IMPLEMENTED,
        detail="Token validation is not configured yet",
    )
FROM node:24.21.0-bookworm
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 4200
CMD ["npm", "start"]
{
  "name": "four-container-auth-frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": { "start": "ng serve --host 0.0.0.0" },
  "dependencies": {
    "@angular/common": "22.2.2",
    "@angular/compiler": "22.2.2",
    "@angular/core": "22.2.2",
    "@angular/platform-browser": "22.2.2",
    "keycloak-js": "26.2.4",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.16.3"
  },
  "devDependencies": {
    "@angular/build": "22.2.2",
    "@angular/cli": "22.2.2",
    "@angular/compiler-cli": "22.2.2",
    "typescript": "6.0.3"
  }
}
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "frontend": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "polyfills": ["zone.js"],
            "tsConfig": "tsconfig.app.json",
            "assets": [],
            "styles": ["src/styles.css"]
          },
          "configurations": {
            "production": { "outputHashing": "all" },
            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": { "buildTarget": "frontend:build:production" },
            "development": { "buildTarget": "frontend:build:development" }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "bundler",
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "lib": ["ES2022", "dom"]
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "outDir": "./out-tsc/app", "types": [] },
  "files": ["src/main.ts"],
  "include": ["src/**/*.d.ts"]
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Four-Container Authentication Stack</title>
    <base href="/">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body><app-root></app-root></body>
</html>
import 'zone.js';
import 'zone.js/plugins/zone-patch-fetch';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
bootstrapApplication(App).catch((error: unknown) => console.error(error));
html { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button { font: inherit; }
import { ChangeDetectionStrategy, Component } from '@angular/core';
import Keycloak from 'keycloak-js';

@Component({
  selector: 'app-root',
  changeDetection: ChangeDetectionStrategy.Eager,
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  private readonly keycloak = new Keycloak({
    url: 'http://localhost:8080',
    realm: 'stack-lab',
    clientId: 'angular-spa'
  });
  authenticated = false;
  subject = 'anonymous';
  authStatus = 'Initializing Keycloak';
  publicResult = 'Not called yet';
  privateResult = 'Not called yet';

  constructor() { void this.initializeAuthentication(); }
  async login(): Promise<void> { await this.keycloak.login({ redirectUri: window.location.origin }); }
  async logout(): Promise<void> { await this.keycloak.logout({ redirectUri: window.location.origin }); }
  async callPublicApi(): Promise<void> { this.publicResult = await this.callApi('http://localhost:8000/health'); }

  async callPrivateApi(): Promise<void> {
    const headers: Record<string, string> = {};
    if (this.keycloak.authenticated && this.keycloak.token) {
      await this.keycloak.updateToken(30);
      headers['Authorization'] = `Bearer ${this.keycloak.token}`;
    }
    this.privateResult = await this.callApi('http://localhost:8000/private', headers);
  }

  private async initializeAuthentication(): Promise<void> {
    try {
      this.authenticated = await this.keycloak.init({
        pkceMethod: 'S256',
        scope: 'fastapi-audience',
        checkLoginIframe: false
      });
      this.subject = this.keycloak.subject ?? 'anonymous';
      this.authStatus = this.authenticated ? 'Authenticated through Keycloak' : 'Anonymous session';
    } catch (error: unknown) {
      this.authStatus = `Keycloak is not configured yet: ${this.messageFrom(error)}`;
    }
  }

  private async callApi(url: string, headers: Record<string, string> = {}): Promise<string> {
    try {
      const response = await fetch(url, { headers });
      const body: unknown = await response.json();
      return `${response.status} ${response.statusText}\n${JSON.stringify(body, null, 2)}`;
    } catch (error: unknown) {
      return `Request failed: ${this.messageFrom(error)}`;
    }
  }

  private messageFrom(error: unknown): string {
    return error instanceof Error ? error.message : String(error);
  }
}
<main class="shell">
  <header>
    <p class="eyebrow">Docker Compose identity lab</p>
    <h1>Four-Container Authentication Stack</h1>
    <p>Angular, FastAPI, Keycloak, and PostgreSQL are running as separate services.</p>
  </header>
  <section class="status-grid">
    <article><span>Authentication</span><strong>{{ authStatus }}</strong></article>
    <article><span>Token subject</span><strong>{{ subject }}</strong></article>
  </section>
  <section class="actions">
    <button type="button" (click)="callPublicApi()">Call public API</button>
    <button type="button" (click)="callPrivateApi()">Call protected API</button>
    <button type="button" (click)="login()" [disabled]="authenticated">Sign in</button>
    <button type="button" (click)="logout()" [disabled]="!authenticated">Sign out</button>
  </section>
  <section class="results">
    <article><h2>Public response</h2><pre>{{ publicResult }}</pre></article>
    <article><h2>Protected response</h2><pre>{{ privateResult }}</pre></article>
  </section>
</main>
:host { display: block; }
.shell { width: min(960px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0; }
header, article, .actions { border: 1px solid #334155; border-radius: 1rem; background: #111827; padding: 1.25rem; }
.eyebrow, article span { color: #38bdf8; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.78rem; }
.status-grid, .results { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; margin-top: 1rem; }
article strong { display: block; margin-top: 0.5rem; overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
button { border: 0; border-radius: 0.65rem; padding: 0.75rem 1rem; background: #0284c7; color: white; cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.45; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: #cbd5e1; }

Configure Keycloak for the SPA and API

Your Docker Compose stack is running. The Angular dashboard can already reach the FastAPI service.

A bearer token check can only prove that a header exists. It cannot establish who signed in.

Keycloak must also define which API should receive the token. That identity configuration gives the dashboard a trustworthy sign-in path.

In this step, get ready to:
  • Create the stack-lab realm with a local test user.
  • Register angular-spa as a public browser client.
  • Configure fastapi-api as the intended audience for SPA tokens.
Create the realm and test user

A realm isolates one set of users from other Keycloak applications. The test user gives the SPA a local identity for the sign-in flow.

  • Open the Keycloak Admin Console in your browser at http://localhost:8080.
  • Enter admin in the Username field.
  • Enter local-dev-only in the Password field.
  • Submit the administrator sign-in form.
  • Click Manage realms in the left column.
  • Click Create realm.
  • Enter stack-lab in the Realm name field.
  • Click Create.

What does the realm separate?

The master realm manages the Keycloak administrator. The stack-lab realm contains the identities used by this application.

Keeping application users in their own realm gives the authentication stack a clear boundary.

You will see stack-lab beside Current realm. This confirms that the application realm is active.

  • Click Users in the left menu.
  • Click Create new user.
  • Enter your test username in the Username field.
  • Click Create.

The test credential stays inside this local lab. Your real accounts remain separate from the experiment.

  • Click Credentials at the top of the user page.
  • Enter a unique throwaway password in the password field.
  • Re-enter the same password in the confirmation field.
  • Set Temporary to Off.
  • Save the password.

You have the fiddly identity setup behind you. Your test user can sign in without a forced password reset.

Can't create the realm or user?

  • Confirm that the current realm is master before looking for Manage realms.
  • Check the local administrator credentials if the sign-in form returns to the login page.
  • Confirm that the keycloak service is still running if the Admin Console stops responding.

Ask for help with the local identity setup: Help me diagnose why I cannot create a Keycloak realm or local test user.

Register the SPA and API clients

Angular uses the Authorization Code flow with PKCE through a public OpenID Connect client. A browser client cannot safely keep a client secret.

Why Keycloak for identity?

Keycloak owns sign-in plus token issuance. FastAPI remains a resource server that evaluates the access token it receives.

Application-managed authentication would add password storage plus token issuance to the API. This structure keeps those identity responsibilities inside the dedicated provider.

  • Click Clients in the left menu of the Admin Console.
  • Click Create client.
  • Select OpenID Connect under Client type.
  • Enter angular-spa in the Client ID field.
  • Click Next.
  • Set Client authentication to Off.
  • Confirm that Standard flow remains enabled.
  • Click Next.
  • Enter http://localhost:4200/* under Valid redirect URIs.
  • Enter http://localhost:4200 under Web origins.
  • Click Save.

The angular-spa client now accepts authentication callbacks only beneath the local dashboard address.

  • Return to Clients from the left menu.
  • Click Create client.
  • Select OpenID Connect under Client type.
  • Enter fastapi-api in the Client ID field.
  • Click Next.
  • Leave the client capability defaults unchanged for this API identifier.
  • Click Next.
  • Click Save.

Keycloak now has separate identities for the browser application plus the protected API.

Client settings look different?

  • Confirm that angular-spa has Client authentication set to Off.
  • Confirm that the redirect URI ends with /*.
  • Confirm that the web origin has no trailing wildcard.

Get help comparing the client settings: Help me check my Keycloak SPA client configuration.

Add the audience scope and verify sign-in

An access token carries an audience that identifies its intended recipient. The optional scope adds fastapi-api to aud when Angular requests fastapi-audience.

  • Click Client scopes in the left menu.
  • Click Create client scope.
  • Enter fastapi-audience as the client scope name.
  • Set Type to None.
  • Save the client scope.
  • Click Mappers.
  • Click Configure a new mapper.
  • Select Audience.
  • Select fastapi-api under Included Client Audience.
  • Leave Add to access token set to On.
  • Save the mapper.

What does the audience mapper prove?

The mapper places fastapi-api in the token audience. FastAPI can later reject tokens intended for a different service.

The scope remains optional because the Angular adapter explicitly requests fastapi-audience during initialization.

  • Click Clients in the left menu.
  • Select angular-spa from the client list.
  • Click Client scopes.
  • Click Add client scope.
  • Select fastapi-audience.
  • Click Add as Optional.

The SPA can now request the audience scope during its existing Keycloak initialization.

  • Switch back to the dashboard tab from earlier.
  • Refresh http://localhost:4200.
  • Click Sign in.
  • Enter your test username in the Keycloak username field.
  • Enter the local test password you created earlier.
  • Submit the Keycloak sign-in form.

You will return to the dashboard with Authenticated through Keycloak in the authentication card. The token subject card displays the signed-in identity.

That callback is the first complete sign-in through your identity provider. The SPA now holds its access token in memory.

Before you call the protected endpoint, do you think FastAPI sees an anonymous request or a bearer token?

  • Click Call protected API.

You will see 501 Not Implemented in the protected response. This intended result proves that HTTPBearer received credentials before the temporary handler stopped the request.

Still seeing an anonymous response?

  • Confirm that the dashboard authentication card says Authenticated through Keycloak.
  • Sign out from the dashboard if you attached the scope after your current login.
  • Sign in again to obtain a fresh access token.
  • Recheck the angular-spa redirect URI if Keycloak does not return to the dashboard.

Ask for help tracing the sign-in flow: Help me find why my signed-in protected request still returns 401.

Keycloak can now identify the user plus issue a token for fastapi-api. Next up, you will replace the temporary response with cryptographic token validation inside FastAPI.

Validate Keycloak Tokens in FastAPI

Your dashboard now signs in through Keycloak. A bearer access token reaches the temporary FastAPI handler. The 501 response proves that the browser supplied credentials.

A credential header cannot prove who issued its token. In this step, PyJWT retrieves the matching public key from the JWKS endpoint. It then validates the JWT signature and required claims before the private handler runs.

In this step, get ready to:
  • Add cryptographic JWT support to the backend image.
  • Replace the temporary token-presence check with signature and claim validation.
  • Compare the signed-in 200 response with the signed-out 401 response.
Add cryptographic token support

The backend needs cryptographic support to verify a token against Keycloak's public signing key. The dependency belongs inside the backend image so the host machine stays unchanged.

  • In VS Code, select backend/requirements.txt.
  • Replace the file contents by copying the dependency list below:
fastapi[standard-no-fastapi-cloud-cli]==0.143.0
PyJWT[crypto]==2.15.1

What do these dependencies provide?

  • The FastAPI package supplies the API framework and server command used by the container.
  • The PyJWT crypto extra supplies token decoding plus the cryptographic support needed for signature verification.
  • Save backend/requirements.txt.
  • Rebuild only the backend service by running this command from the project root:
docker compose up --build --detach backend

What does this command do?

The command rebuilds the backend image with the updated dependency file. The detached backend container then replaces the previous backend container.

You will see the backend image build successfully. The backend service returns to its running state.

Backend build not completing?

  • Confirm that backend/requirements.txt contains both dependency lines exactly.
  • Confirm that the backend build context still points to ./backend in compose.yaml.

Help me diagnose why the backend image does not rebuild after updating its dependency file.

✔️ Awesome, I've got everything!

Your backend dependency file now includes the cryptographic JWT library.

ⓧ I'd like to double check the full code

fastapi[standard-no-fastapi-cloud-cli]==0.143.0
PyJWT[crypto]==2.15.1

What should match?

The file contains the FastAPI dependency first. The PyJWT crypto dependency appears on the second line.

Prepare the token verifier

Token validation crosses two network perspectives. The backend reaches Keycloak through Docker Compose using container DNS at http://keycloak:8080/realms/stack-lab/protocol/openid-connect/certs.

The browser-issued token records its issuer as http://localhost:8080/realms/stack-lab. FastAPI preserves that exact issuer during validation.

  • In VS Code, select backend/app/main.py.
  • Select the imports and application setup above the public health route.
  • Replace that selection by copying the code below:
import os
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import InvalidTokenError, PyJWKClientError

KEYCLOAK_JWKS_URL = os.environ["KEYCLOAK_JWKS_URL"]
KEYCLOAK_ISSUER = os.environ["KEYCLOAK_ISSUER"]
KEYCLOAK_AUDIENCE = os.environ["KEYCLOAK_AUDIENCE"]
FRONTEND_ORIGIN = os.environ["FRONTEND_ORIGIN"]

app = FastAPI(title="Four-Container Authentication API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[FRONTEND_ORIGIN],
    allow_credentials=False,
    allow_methods=["GET"],
    allow_headers=["Authorization", "Content-Type"],
)
bearer = HTTPBearer()
jwks_client = PyJWKClient(KEYCLOAK_JWKS_URL)

What does this setup do?

  • The environment variables load the JWKS address and the expected token claims from compose.yaml.
  • CORS permits the Angular origin to make GET requests with an authorization header.
  • HTTPBearer extracts bearer credentials before a protected handler runs.
  • PyJWKClient prepares the backend to retrieve Keycloak's current public signing keys.
  • Save backend/app/main.py.
  • Rebuild the backend with the updated setup by running:
docker compose up --build --detach backend

What does this rebuild prove?

Docker Compose imports the updated module inside a fresh backend container. A running service confirms that the imports and application setup load successfully.

  • Return to the dashboard from earlier.
  • Click Call public API.

You will see HTTP 200 with the public FastAPI health response. This confirms that the existing route still works after the new setup loads.

Public call no longer working?

  • Check that the public health route remains below the new setup in backend/app/main.py.
  • Check that each environment variable name matches compose.yaml exactly.

Help me find why the FastAPI health route stopped working after I added the PyJWT imports and JWKS client.

The verifier turns raw bearer credentials into trusted claims. Any signature failure or claim failure becomes a controlled unauthorized response.

  • In backend/app/main.py, place the cursor above the public health route.
  • Add the verifier by copying the function below:
def verify_token(credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)]) -> dict[str, object]:
    token = credentials.credentials
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        return jwt.decode(
            token,
            signing_key,
            audience=KEYCLOAK_AUDIENCE,
            issuer=KEYCLOAK_ISSUER,
            options={"require": ["exp", "iss", "sub", "aud"]},
        )
    except (InvalidTokenError, PyJWKClientError) as error:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired access token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from error

How does the verifier establish trust?

  • The verifier reads the token from the credentials extracted by HTTPBearer.
  • The get_signing_key_from_jwt() call selects the public key that matches the token.
  • The decode call checks the signature against that key.
  • The decode options require exp, iss, sub, and aud claims.
  • Save backend/app/main.py.
  • Rebuild the backend with the verifier by running:
docker compose up --build --detach backend

What happens during this rebuild?

The backend imports the verifier when FastAPI starts. The service remains running only if the updated Python module loads successfully.

  • Inspect the four Compose services by running:
docker compose ps

What does the status check show?

The status command lists the containers in this Compose application. It also shows their current state and published ports.

You will see all four services running. PostgreSQL remains healthy.

Backend missing from the running services?

  • Check the indentation inside verify_token().
  • Check that every imported exception name matches the verifier.
  • Check that the four environment keys remain under the backend service in compose.yaml.

Help me debug why the backend container stops after I add the token verifier.

Enforce validation on the private route

The temporary private handler responds after any bearer token reaches it. The final handler depends on verify_token() and receives only the claims returned by successful validation.

  • In backend/app/main.py, select the temporary private route from its decorator through its response.
  • Replace the selected handler by copying the final route below:
@app.get("/private")
def read_private(claims: Annotated[dict[str, object], Depends(verify_token)]) -> dict[str, object]:
    return {
        "message": "Keycloak access token accepted",
        "subject": claims["sub"],
        "audience": claims["aud"],
    }

What does the final route return?

  • FastAPI runs verify_token() before it calls read_private().
  • The handler returns the validated token subject.
  • The handler also returns the validated audience.
  • Save backend/app/main.py.
  • Rebuild the final backend service by running:
docker compose up --build --detach backend

What changed in the backend?

The rebuilt backend now routes every private request through the token verifier. The frontend and identity services keep running without a rebuild.

  • Return to the signed-in dashboard.

Before you click, consider whether the signed token now passes every configured check.

  • Click Call protected API.

You will see HTTP 200. The JSON response includes Keycloak access token accepted, the token subject, and the fastapi-api audience.

You have crossed the key security checkpoint. FastAPI now accepts only a token that survives every configured validation.

A logout removes the in-memory token from the dashboard. The next request tests whether frontend state can bypass backend enforcement.

Before you repeat the call, consider whether any browser state can replace the missing token.

  • Click Sign out.
  • Click Call protected API.

You will see HTTP 401 in the protected result. This proves that the API still enforces its security boundary after the browser session ends.

Protected response not matching the checkpoint?

  • Sign in again if the first protected call returns 401.
  • Confirm that the access token includes the fastapi-api audience if the signed-in call remains unauthorized.
  • Confirm that KEYCLOAK_JWKS_URL uses the keycloak service name.

Help me diagnose why my signed-in protected request does not return HTTP 200.

💡 What did the API prove?

Authentication establishes the caller's identity through a valid token. Authorization can then decide what that identified caller may do.

  • Signature validation proves that the token matches Keycloak's signing key.
  • Issuer validation restricts tokens to the expected realm.
  • Expiration validation rejects tokens outside their valid lifetime.
  • Audience validation restricts tokens to the FastAPI resource server.

✔️ Awesome, I've got everything!

Your final backend validates Keycloak access tokens before returning private data.

ⓧ I'd like to double check the full code

import os
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import InvalidTokenError, PyJWKClientError

KEYCLOAK_JWKS_URL = os.environ["KEYCLOAK_JWKS_URL"]
KEYCLOAK_ISSUER = os.environ["KEYCLOAK_ISSUER"]
KEYCLOAK_AUDIENCE = os.environ["KEYCLOAK_AUDIENCE"]
FRONTEND_ORIGIN = os.environ["FRONTEND_ORIGIN"]

app = FastAPI(title="Four-Container Authentication API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[FRONTEND_ORIGIN],
    allow_credentials=False,
    allow_methods=["GET"],
    allow_headers=["Authorization", "Content-Type"],
)
bearer = HTTPBearer()
jwks_client = PyJWKClient(KEYCLOAK_JWKS_URL)


def verify_token(credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)]) -> dict[str, object]:
    token = credentials.credentials
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        return jwt.decode(
            token,
            signing_key,
            audience=KEYCLOAK_AUDIENCE,
            issuer=KEYCLOAK_ISSUER,
            options={"require": ["exp", "iss", "sub", "aud"]},
        )
    except (InvalidTokenError, PyJWKClientError) as error:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired access token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from error


@app.get("/health")
def read_health() -> dict[str, str]:
    return {"status": "ok", "service": "fastapi"}


@app.get("/private")
def read_private(claims: Annotated[dict[str, object], Depends(verify_token)]) -> dict[str, object]:
    return {
        "message": "Keycloak access token accepted",
        "subject": claims["sub"],
        "audience": claims["aud"],
    }

What should match?

The full file contains the environment-backed Keycloak settings and CORS configuration. It also contains the verifier plus the public and private routes.

Secret mission

Add Role-Aware UI

Give your test user the `stack-user` realm role. Then extend the Angular dashboard so assigned users see a role-aware panel after receiving a fresh token.

Clean Up Your Resources

Clean Up Your Resources

Your local Docker Compose stack has no ongoing cloud cost. Choose whether to keep it running, pause it, or delete it.

Resources you used:

  • The Angular frontend container.
  • The FastAPI backend container.
  • The Keycloak identity provider container.
  • The PostgreSQL database container.
  • The Compose default network.
  • The locally built image for the frontend service.
  • The locally built image for the backend service.
  • The named volume called keycloak_postgres_data.
  • The local four-container-auth-stack workspace.

Keep everything running

No action needed. Choose this if you want the authentication stack ready for more testing.

  • Keep all four services available on your workstation.
  • Keep the stack-lab realm configuration in the named volume.
  • Keep the test user with its stack-user role assignment.
  • Expect the running containers to continue using local memory and processor capacity.

Pause - I'll come back to this later

Shut down the running processes to free local resources. Choose one of these pause options based on how quickly you want to restart.

  • Stop the stack while keeping its containers available by running this command from four-container-auth-stack:
docker compose stop

What does this keep?

The containers stop using active runtime resources. The named volume keeps your realm configuration.

  • Remove the containers while preserving identity data by running this alternative command:
docker compose down

What does this remove?

This removes the four containers. It also removes the Compose default network.

The keycloak_postgres_data volume remains available for your next launch.

  • Restart the paused stack later by running:
docker compose up --detach

How does the stack return?

Compose restarts existing containers or recreates missing ones. It reuses the declared named volume.

Delete - I don't want to use this again

Deleting the named volume is permanent. Your source files remain available until you remove the workspace separately.

  • Remove the containers, default network, and persistent identity data by running this command from four-container-auth-stack:
docker compose down --volumes

What does this delete?

Compose removes the four containers. It also removes the default network and keycloak_postgres_data volume.

The complete stack-lab realm configuration disappears with the database data.

  • Return to Docker Desktop.
  • Select the images area in the left sidebar.
  • Find the locally built image associated with the frontend service.
  • Delete the frontend image using the delete control in its row.
  • Find the locally built image associated with the backend service.
  • Delete the backend image using the delete control in its row.

Local Files Remain

The Compose command leaves the four-container-auth-stack workspace on your workstation. The next command removes that final local resource.

  • Switch back to Git Bash.
  • Delete the local workspace by running these commands from inside four-container-auth-stack:
cd ..
rm -rf four-container-auth-stack

What do these commands remove?

  • The first command moves Git Bash to the parent folder.
  • The second command permanently deletes the four-container-auth-stack workspace.

That's the cleanup complete. The project runtime state and persistent identity data are gone.

Nice Work!

Nice Work!

Project complete. Your four-container stack authenticates users through Keycloak before FastAPI independently validates each access token.

You've learned how to:

  • Built a Docker Compose application with exactly four long-running services. Angular serves the dashboard. FastAPI serves the API. Keycloak manages identity. PostgreSQL preserves the realm data.
  • Traced the security boundary through four API outcomes. Anonymous requests returned 401. Token-present requests reached 501. Authenticated requests returned 200. Post-logout requests returned 401.
  • Configured an OpenID Connect login with PKCE. Added the fastapi-api audience. Used JWKS to validate the JWT signature. Required the exp, iss, sub, and aud claims.
  • Secret Mission: Created the stack-user realm role. Assigned it to the test user. Built a role-aware Angular panel using keycloak.hasRealmRole('stack-user') while keeping API enforcement in FastAPI.

Ready to quiz yourself?