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-PolicyFor 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-configurationIt 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-PolicyX-Content-Type-OptionsReferrer-PolicyX-Frame-OptionsStrict-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.propertiesAnd 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/authWhy?
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 → APIsOption 2 (What we chose)
Quarkus → Everything
Keycloak UI CSP → DisabledFinal 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:roValidation
Direct Keycloak endpoint matrix:
46 page/API/static URLs checked
0 missing CSP
0 duplicate CSPFinal 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 everythingIf you're dealing with VAPT findings around Keycloak:
Fix it once. Fix it properly.