Post

Keycloak Client Setups: Bearer-Only Resource Server + PKCE Frontend Client (Keycloak 26)

Keycloak Client Setups: Bearer-Only Resource Server + PKCE Frontend Client (Keycloak 26)

When securing a modern web application that has a JavaScript frontend talking to a protected REST API, you need two distinct Keycloak client configurations. A common mistake is reusing one client for both roles — this creates security gaps and blurs the trust boundaries in your architecture.

This post walks through the correct pattern: a bearer-only resource server client for the backend API, and a PKCE public client for the frontend, with role-based access wired together through a dedicated client scope.


The Two-Client Pattern

The architecture is straightforward:

ComponentKeycloak Client TypeAccess TypePurpose
Backend APIBearer-onlyConfidentialValidates incoming JWTs, never issues tokens
Frontend SPAPublic + PKCEPublicExchanges user credentials for tokens

The backend never participates in a login flow — it only validates tokens. The frontend drives the entire OAuth2 Authorization Code flow with PKCE and receives a token that carries the roles the backend will check.


Step 1: Create the Resource Server (Bearer-Only) Client

In the Keycloak Admin Console, navigate to your realm and create a new client for your backend API.

Settings:

  • Client ID: my-api
  • Client authentication: On
  • Authentication flow: Disable Standard flow, Direct access grants, and all other flows

With all flows disabled, Keycloak will refuse to issue tokens for this client but will still validate incoming tokens — this is the modern equivalent of the old bearer-only access type.

Create a Role on the Resource Server Client

Inside the my-api client, navigate to the Roles tab and create a role:

  • Role name: api-reader (or whatever matches your API’s access model)

This role represents the permission required to call your protected API endpoints.


Step 2: Create the Frontend (PKCE Public) Client

Create a second client for your Single Page Application.

Settings:

  • Client ID: my-frontend
  • Client authentication: Off (Public — no client secret, safe for browser-based apps)
  • Authentication flow: Enable Standard flow only
  • Valid redirect URIs: http://localhost:3000/* (your SPA’s origin)
  • Web origins: http://localhost:3000 (for CORS)

Never enable Direct access grants (Resource Owner Password Credentials) on a public client facing the browser. This bypasses the consent and PKCE protections.

Enable PKCE

Under Advanced Settings for my-frontend:

  • Proof Key for Code Exchange Code Challenge Method: S256

This forces the authorization code flow to require a code_verifier / code_challenge pair, preventing authorization code interception attacks in the browser.


Step 3: Add the Resource Role to the Frontend Client’s Dedicated Scope

Instead of building a separate client scope with a mapper, add the api-reader role directly to the my-frontend client’s assigned roles (dedicated scope). This tells Keycloak: only include this role in tokens issued for this client, and only if the authenticated user actually has it.

  1. Go to my-frontendClient Scopes → select the Dedicated tab
  2. Click Assign role → switch the filter to Filter by clients
  3. Select my-api / api-reader and confirm

The role will now appear in the access token under resource_access.my-api.roles whenever a user who holds it logs in through my-frontend. No custom scope, no extra mapper needed.


Step 4: Create a Group and Assign the Role

Assigning roles directly to individual users does not scale. The standard approach is to create a group, assign the role to the group, and then manage access purely by group membership.

Create the Group

  1. Go to GroupsCreate group
  2. Name: api-readers

Assign the Role to the Group

  1. Open api-readersRole mappingAssign role
  2. Filter by clients, select my-api / api-reader, and confirm

Add Users to the Group

  1. Go to Users → open a user → GroupsJoin group → select api-readers

From this point on, any user in api-readers who logs in via my-frontend will receive an access token with the my-api:api-reader role included automatically — no per-user role assignment required.


The Full Auth Flow

Here is the complete sequence from browser login to a protected API call:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "background":           "#0d1117",
    "mainBkg":              "#161b22",
    "secondaryColor":       "#161b22",
    "tertiaryColor":        "#0d1117",
    "primaryColor":         "#161b22",
    "primaryBorderColor":   "#00d4ff",
    "primaryTextColor":     "#e6edf3",
    "lineColor":            "#00d4ff",
    "signalColor":          "#00d4ff",
    "signalTextColor":      "#e6edf3",
    "actorBkg":             "#161b22",
    "actorBorder":          "#00d4ff",
    "actorTextColor":       "#e6edf3",
    "actorLineColor":       "#4d9fff",
    "activationBkgColor":   "#0d1117",
    "activationBorderColor":"#00d4ff",
    "labelBoxBkgColor":     "#161b22",
    "labelBoxBorderColor":  "#00d4ff",
    "labelTextColor":       "#e6edf3",
    "loopTextColor":        "#e6edf3",
    "noteBkgColor":         "#0d2233",
    "noteBorderColor":      "#00d4ff",
    "noteTextColor":        "#e6edf3",
    "sequenceNumberColor":  "#0d1117",
    "edgeLabelBackground":  "#161b22",
    "fontFamily":           "ui-monospace, monospace"
  }
}}%%
sequenceDiagram
    actor User
    participant SPA as Frontend SPA<br/>(my-frontend)
    participant KC as Keycloak
    participant API as Backend API<br/>(my-api)

    User->>SPA: Opens application
    SPA->>SPA: Generate code_verifier + code_challenge (PKCE S256)

    SPA->>KC: GET /auth<br/>client_id=my-frontend<br/>scope=openid<br/>code_challenge=...

    KC->>User: Redirect to Login Page
    User->>KC: Submit credentials

    KC->>KC: Authenticate user<br/>Resolve group membership<br/>User is in api-readers group
    KC->>KC: Build token claims<br/>api-reader role is in my-frontend dedicated scope
    KC->>SPA: Redirect with authorization code

    SPA->>KC: POST /token<br/>grant_type=authorization_code<br/>code + code_verifier

    KC->>KC: Verify code_verifier matches code_challenge
    KC-->>SPA: Access Token (JWT) + ID Token<br/>resource_access.my-api.roles: [api-reader]

    SPA->>API: GET /api/protected<br/>Authorization: Bearer access_token

    API->>API: Validate JWT signature<br/>Check issuer + expiry<br/>Verify resource_access.my-api.roles contains api-reader

    API-->>SPA: 200 OK + Protected Data
    SPA-->>User: Render Data

Token Claims: What the Backend Sees

When the backend receives the access token and decodes the JWT payload, it will find the role nested under resource_access:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "exp": 1710503000,
  "iat": 1710502700,
  "iss": "https://keycloak.example.com/realms/my-realm",
  "aud": "my-api",
  "sub": "a1b2c3d4-...",
  "scope": "openid",
  "resource_access": {
    "my-api": {
      "roles": [
        "api-reader"
      ]
    }
  }
}

The role appears automatically because the user is a member of the api-readers group, which carries the my-api:api-reader role, and that role is part of my-frontend’s dedicated scope. Your backend framework (Spring Security, Quarkus, FastAPI, etc.) can map resource_access.<client-id>.roles to granted authorities out of the box.


Backend Validation Checklist

When your API validates an incoming token, it must verify:

  • Signature — token is signed by your Keycloak realm’s public key
  • Issuer (iss) — matches your Keycloak realm URL
  • Audience (aud) — contains my-api
  • Expiry (exp) — token has not expired
  • Roleresource_access.my-api.roles contains the required role

Never skip audience validation. Without it, a token issued for a completely different client could be replayed against your API.


Why Not One Client for Both?

A common shortcut is to set up a single confidential client and use it for both the frontend and the backend. This causes several problems:

  • The client secret leaks into the browser if used by the SPA
  • The backend would need to participate in token issuance (not its job)
  • PKCE cannot be combined with bearer-only in a single client
  • Role management becomes a tangled mess as the app grows

Keeping clients separated gives you clear trust boundaries: the public PKCE client owns authentication, the bearer-only client owns authorization. Each does one thing well.


Summary

TaskWhere
Define the permission (role)my-api → Roles tab
Add role to frontend tokenmy-frontend → Client Scopes → Dedicated → Assign role
Group users by access levelGroups → api-readers → Role mapping → my-api/api-reader
Grant access to a userAdd user to api-readers group
Force PKCE for the SPAmy-frontend → Advanced → Code Challenge Method: S256
Validate the token on every requestBackend API — signature, issuer, audience, role

This pattern scales cleanly: to grant API access to a new user, add them to the group. To add a second frontend, create a new public client and assign the same role to its dedicated scope — no changes to the backend or the resource server client needed.

This post is licensed under CC BY 4.0 by the author.