When one of our applications recently went through a VAPT (Vulnerability Assessment and Penetration Testing), we expected a few standard findings.

But one of them stood out.

Some Keycloak endpoints were not returning a Content-Security-Policy (CSP) header.

At first, this sounded simple.

We had already configured CSP from the Keycloak admin UI. The login pages were returning CSP correctly. Static resources looked fine. Everything seemed in place.

But then came the actual requirement:

CSP should be present on all endpoints, including APIs that return JSON.

That's where things got interesting.

The Setup

The sample project uses:

  • Angular 20
  • Keycloak 26.1.2
  • Docker Compose
  • PostgreSQL for Keycloak
  • OIDC normal login
  • SAML login
  • A default test user
  • CSP headers for Keycloak pages, static resources, and APIs

Demo credentials:

Keycloak admin:
URL: http://localhost:8080/admin
Username: admin
Password: admin123
Application user:
URL: http://localhost:4200
Username: testuser
Password: password
Realm: saml-demo

👉 You can run the full sample project here: https://github.com/allen246/keycloak-csp-application/blob/main/docker-compose.yml

First Approach: Configure CSP from the Keycloak UI

Keycloak already supports browser security headers at the realm level.

Keycloak Admin Console
Realm settings
Security defenses
Headers
Content-Security-Policy

For a simple local setup, the page-level CSP can look like this:

frame-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self' http://localhost:4200;

This works well for:

  • Login pages
  • Account pages
  • Browser-rendered content

The same value can also be defined in a realm import:

{
  "browserSecurityHeaders": {
    "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self' http://localhost:4200;",
    "contentSecurityPolicyReportOnly": "",
    "xContentTypeOptions": "nosniff",
    "referrerPolicy": "no-referrer",
    "xFrameOptions": "SAMEORIGIN",
    "xXSSProtection": "1; mode=block",
    "strictTransportSecurity": "max-age=31536000; includeSubDomains"
  }
}

But there was one limitation.

This does NOT cover all API responses.

Why CSP on JSON APIs Feels Strange

Consider this endpoint:

http://localhost:8080/realms/saml-demo/.well-known/openid-configuration

It returns JSON.

The browser is not treating it as a page. No scripts. No DOM. No rendering.

So from a pure browser security perspective:

CSP matters most for HTML documents — not JSON APIs.

But VAPT tools and security baselines expect headers like:

  • Content-Security-Policy
  • X-Content-Type-Options
  • Referrer-Policy
  • X-Frame-Options
  • Strict-Transport-Security

on every endpoint.

So we adapted.

For APIs, we used a strict CSP:

default-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none';

The Better Approach: Quarkus HTTP Filters

This is where things became clean and scalable.

Instead of relying only on Keycloak UI, we used Quarkus HTTP filters to inject CSP headers based on endpoint patterns.

We created:

./keycloak/conf/quarkus.properties

And grouped CSP rules by endpoint type:

quarkus.http.filter.keycloak_page_csp.matches=(/admin/?|/admin/[^/]+/console/?|/realms/[^/]+/(login-actions(/|$).*|protocol/openid-connect/(auth/?|3p-cookies(/|$).*|login-status-iframe\\.html)|protocol/saml/clients(/|$).*))
quarkus.http.filter.keycloak_page_csp.header."Content-Security-Policy"=frame-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self' http://localhost:4200;
quarkus.http.filter.keycloak_discovery_api_csp.matches=(/\\.well-known(/|$).*|/realms/[^/]+(/\\.well-known(/|$).*|/?$))
quarkus.http.filter.keycloak_discovery_api_csp.header."Content-Security-Policy"=default-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none';
quarkus.http.filter.keycloak_protocol_api_csp.matches=/realms/[^/]+/protocol/(openid-connect/(token(/introspect)?|userinfo|certs|revoke|auth/device|ext/(ciba/auth|par/request)|login-status-iframe\\.html/init)|saml/(descriptor|resolve))(/|$).*
quarkus.http.filter.keycloak_protocol_api_csp.header."Content-Security-Policy"=default-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none';
quarkus.http.filter.keycloak_admin_api_csp.matches=(/admin/(realms|serverinfo|whoami|client-description-converter)(/|$).*|/admin/[^/]+/console/whoami(/|$).*)
quarkus.http.filter.keycloak_admin_api_csp.header."Content-Security-Policy"=default-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none';
quarkus.http.filter.keycloak_static_resource_csp.matches=(/resources(/|$).*|/js(/|$).*)
quarkus.http.filter.keycloak_static_resource_csp.header."Content-Security-Policy"=default-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none';

The SHA-Based CSP Attempt (and Why We Dropped It)

We briefly explored using SHA-based CSP (hashing inline scripts).

In theory, it's more secure.

In practice:

  • Keycloak pages are dynamic
  • Hashes kept changing
  • It became unmanageable

This approach was not practical for our setup.

So we intentionally ignored it.

The Duplicate CSP Problem

Once filters were added, we saw something unexpected.

Some responses returned two CSP headers.

Example:

/realms/master/protocol/openid-connect/auth

Why?

Because both were active:

  • Keycloak UI CSP
  • Quarkus filter CSP

Browsers enforce all CSP headers → not ideal.

The Rule That Fixed Everything

We simplified it to one rule:

Only one layer should own CSP.

Two options:

Option 1

Keycloak UI → Pages
Quarkus → APIs

Option 2 (What we chose)

Quarkus → Everything
Keycloak UI CSP → Disabled

Final Configuration

Realm-level CSP:

{
  "browserSecurityHeaders": {
    "contentSecurityPolicy": "",
    "contentSecurityPolicyReportOnly": "",
    "xContentTypeOptions": "nosniff",
    "referrerPolicy": "no-referrer",
    "xFrameOptions": "SAMEORIGIN",
    "xXSSProtection": "1; mode=block",
    "strictTransportSecurity": "max-age=31536000; includeSubDomains"
  }
}

Docker Compose Setup

services:
  keycloak:
    image: bitnamilegacy/keycloak:26.1.2-debian-12-r0
    container_name: saml-demo-keycloak
    depends_on:
      - postgresql
    ports:
      - '8080:8080'
    environment:
      KEYCLOAK_ADMIN_USER: admin
      KEYCLOAK_ADMIN_PASSWORD: admin123
      KEYCLOAK_CREATE_ADMIN_USER: 'true'
      KEYCLOAK_DATABASE_HOST: postgresql
      KEYCLOAK_DATABASE_PORT: 5432
      KEYCLOAK_DATABASE_NAME: bitnami_keycloak
      KEYCLOAK_DATABASE_USER: bn_keycloak
      KEYCLOAK_DATABASE_PASSWORD: keycloak_db_password
      KEYCLOAK_EXTRA_ARGS: --import-realm --hostname-strict=false
    volumes:
      - type: bind
        source: ./keycloak/conf/quarkus.properties
        target: /opt/bitnami/keycloak/conf/quarkus.properties
        read_only: true
      - ./keycloak/import:/opt/bitnami/keycloak/data/import:ro
      - ./keycloak/init:/docker-entrypoint-initdb.d:ro

Validation

Direct Keycloak endpoint matrix:
46 page/API/static URLs checked
0 missing CSP
0 duplicate CSP

Final Takeaway

This was a good reminder:

  • CSP is mainly for browsers
  • But audits require consistency
  • Practical solutions matter

The cleanest setup for us:

Realm-level CSP: disabled
Other headers: enabled
Quarkus filters: handle everything

If you're dealing with VAPT findings around Keycloak:

Fix it once. Fix it properly.