Skip to content

Understanding Access Control Design

This guide explains the access control design used in the application. It is written for developers who are new to the project and want to understand both the original idea and the way the current implementation works.

What to remember as a developer

When working on this system, keep these rules in mind.

  • Do not design frontend features that expect direct access to the JWT unless that is explicitly part of a new architecture decision.
  • Treat the session_id cookie as the browser-side authentication artifact.
  • Treat /api/internal/session/exchange as an internal-only mechanism, not a public API.
  • Assume Redis contains the authoritative server-side session mapping.
  • If you work on service-to-service calls around PocketBase or other internal backends, pay attention to internal trust controls such as namespace policy, mTLS, or gateway rules.

Why this design exists

The main goal of the design is to let users authenticate with the platform while reducing the exposure of access tokens in the browser.

In the simplest version of the flow, the frontend authenticates against the identity provider, receives a JWT, and then sends that JWT directly to the API in the Authorization: Bearer ... header. That is straightforward, but it means the browser has direct access to the token. If frontend code, browser storage, or client-side integrations are compromised, the token may be exposed.

--- config: layout: elk --- flowchart LR FE[FE] --> |OAuth| KC[KC / Keycloak] KC[KC / Keycloak] -->|JWT| FE[FE] FE[FE] FE -->|JWT| API[API]

What is weak about it

The token lives in the browser. That increases risk because the token may be accessible to frontend JavaScript, browser extensions, debugging tools, or accidental logging.

Over time, the design shifted toward a safer approach where:

  • the browser no longer holds the JWT,
  • the backend manages tokens centrally,
  • and internal services retrieve tokens only when needed.

This means the browser works with a session, not with the access token itself.


Current Design

The outside world interacts with the frontend and carries only a session cookie.

Across the boundary, the platform manages the real token in Redis and exchanges it only for trusted internal callers.

Internal services communicate using x-internal-token or, in the future, more robust trust mechanisms like Envoy and mTLS.

Step 1 — User login through the BFF

The flow starts in the user-facing zone. The user opens the portal and interacts with the frontend.

The frontend sends the login request to the BFF. The load balancer is planned to support multiple distributed BFF instances in the future.

The BFF then authenticates against Keycloak, receives the JWT, stores it in Redis, and creates a session_id. That session_id is returned to the browser as an HttpOnly cookie.

The browser keeps the session, but never receives the JWT itself.

--- config: layout: elk --- flowchart LR subgraph NorthSouth["User-facing zone"] direction LR User((User)) KC[Keycloak] LB[Load Balancer for multiple BFFs] FE[Frontend / Browser] Cookie[(session_id cookie)] User -->|opens portal| FE FE --> LB Cookie -->|stored in browser| FE end subgraph WestEast["Internal zone"] direction LR BFF[BFF] Redis[(Redis)] JWT[(JWT)] LB -->|login request| BFF BFF -->|authenticate| KC KC -->|returns JWT| BFF BFF -->|store JWT| Redis BFF -->|creates| JWT end BFF -->|HTTP_only set session_id cookie| Cookie

Note

This is the primary and preferred access path. It keeps the JWT inside the platform and gives the browser only a short-lived session handle.

Step 2 — Internal session exchange

When an internal component such as PocketBase needs identity data, it does not ask the browser for a JWT. Instead, it uses the internal exchange endpoint. It sends the session_id, the session is resolved through Redis, and the service receives the JWT and user claims. This happens only inside the Kubernetes boundary.

Note

This exchange path is internal-only. The direct external access to this step should be blocked.

--- config: layout: elk --- flowchart TB %% ========================= %% Outside (blocked access) %% ========================= subgraph NorthSouth["User-facing zone (North-South)"] direction LR User((User)) Browser[Browser / FE] User --> Browser end %% ========================= %% Internal exchange flow %% ========================= subgraph WestEast["Internal zone (East-West)"] direction LR PB[PocketBase / Internal service] Exchange[/internal/session/exchange/] Redis[(Redis)] JWT[(JWT + claims)] PB -->|1. send session_id| Exchange Exchange -->|2. resolve session| Redis Exchange -->|3. return JWT + claims| PB Exchange --> JWT end %% ========================= %% Blocked external access %% ========================= Browser -.->|❌ no direct access| Exchange

Step 3 — Protected internal service communication

The third path represents service-to-service communication inside the cluster. During the transition period, an internal service such as MLC (microservice local client) talks to PocketBase using the x-int-token as an internal-only credential. This token is intended only for East-West traffic and should not be accessible from outside the cluster.

Note

This is a hardening layer. It protects internal requests even when they do not go through the normal browser session flow.

--- config: layout: elk --- flowchart TB %% ========================= %% Outside zone %% ========================= subgraph NorthSouth["User-facing zone (North-South)"] direction LR User((User)) Browser[Browser / FE] User --> Browser end %% ========================= %% Internal protected service flow %% ========================= subgraph WestEast["Internal zone (East-West)"] direction LR MLC[MLC / Internal service] PB[PocketBase] XINT[x-int-token] MLC -->|present internal credential| XINT XINT -.->|validated internally| PB end %% ========================= %% Blocked external access %% ========================= Browser -.->|❌ no access to x-int-token| XINT Browser -.->|❌ no direct access| PB

X-Internal-Token

The X-internal-token is a static, pre-configured token.

It is not generated dynamically and is not tied to a specific request or user session. Instead, it acts as a shared secret that trusted internal services use to authenticate themselves when talking to each other.

PocketBase knows this token in advance and uses it to decide whether to allow or deny access.

The token is environment-specific, which means:

  • each environment (e.g. dev, staging, production) has its own token,
  • services deployed in that environment are configured with the correct value,
  • the token is not shared across environments.

Since the token is static, it's really important to keep it safe. Currently we follow a simple but effective rule to do this:

  • if traffic comes from outside the cluster (North-South) → the x-internal-token header is removed
  • if traffic comes from inside the cluster (East-West) → the header is preserved

This ensures that even if an external client tries to use the token, it will never reach PocketBase. As a result, the token is effectively usable only within the internal network.

The token is a temporary solution

The static X-internal-token will be replaced by a dynamic system handled by the Envoy proxy. The plan is for the Envoy proxy to automatically sign a dynamic token to secure East-West traffic and use mTLS for requests.

How the current access control works

Explore OpenAPI here

A simple end-to-end example

Here is the flow in plain language.

A user opens the frontend and signs in with username and password. The frontend sends the login request to the platform auth endpoint. The backend authenticates the user with Keycloak. Keycloak returns a JWT to the backend, not to the browser. The backend stores the JWT in Redis and creates a session record. The browser receives only a session_id cookie.

Later, the user opens a protected part of the application. The browser sends its normal request with the session cookie. An internal backend component needs the user’s token and claims, so it calls the internal session exchange endpoint with the session identifier. The exchange endpoint validates the session, retrieves the JWT from Redis, and returns the token and claims to that internal service. The service then proceeds with the authorized operation.

At no point does the browser need to hold the JWT directly.


1. User logs in through the frontend

The user uses the frontend login form. The frontend sends the credentials to:

POST /api/auth/login

The BFF receives the request and authenticates against Keycloak.

If authentication succeeds:

  • a JWT is obtained from Keycloak and stored internally
  • a session_id is created
  • the session_id is sent back to the browser in an HttpOnly cookie.

The browser now has a session, not a token.

2. Browser continues to call the platform

After login, the browser makes requests as an authenticated user. Because the session is stored in an HttpOnly cookie, the browser automatically includes that cookie in requests to the platform, but frontend JavaScript should not be able to read it directly.

This is safer than storing a bearer token in local storage or exposing it to application code.

3. Internal services resolve the session to a JWT

When an internal component such as PocketBase or another backend service needs the actual JWT, it calls:

POST /api/internal/session/exchange

That endpoint takes the session_id and returns:

  • a valid flag,
  • the JWT,
  • claims such as user ID, email, groups, and roles.

This endpoint is not meant for browser use. It is meant for internal traffic only.

4. Internal traffic is restricted

The idea is as follows:

  • user traffic enters from the outside through a load balancer,
  • internal exchange endpoints are blocked from external access,
  • only trusted services inside the cluster or trusted namespaces may call them,
  • service-to-service authentication and policy enforcement help protect the exchange step.

That is why the internal exchange endpoint is treated differently from the public login endpoint.


What is stored where

Browser

The browser stores a session_id cookie.

Because it is HttpOnly , it is intended for transport, not for direct reading by frontend code.

Internal storage (Redis)

Redis stores the mapping between session_id and JWT, and probably also user/session metadata.

A simple conceptual representation would be:

session_id -> {
  jwt: "...",
  claims: {...},
  expires_at: ...
}

Keycloak

Keycloak is still the identity source. It validates credentials and issues the original access token.

Why the internal exchange endpoint exists

A novice developer might ask: if the browser already has a session, why does the system need another endpoint to exchange it for a JWT?

The reason is that some internal services still need a real token and claims in order to perform authorized operations. Instead of exposing the JWT to the browser, the system gives that token only to trusted internal callers.

So the exchange endpoint acts like a controlled bridge:

  • public side: session cookie
  • internal side: JWT and claims

This lets the platform keep the browser simpler and safer while still supporting services that expect tokens.