Authentication is a critical aspect of modern web applications, and Keycloak provides a powerful, open-source identity and access management solution. In this article, we’ll explore integrating Keycloak with a Spring Boot backend—using Thymeleaf for server-side rendering—and a React frontend styled with Tailwind CSS 4 and DaisyUI 5 (beta). We’ll use the same credentials across both, leveraging OAuth2 for session-based web authentication and JWT for stateless API security, with endpoint-specific configurations. This approach ensures a unified user experience across traditional web pages and modern single-page applications (SPAs).
Prerequisites
To follow this tutorial, ensure you have:
- Java 17+: Required for Spring Boot 3.x.
- Node.js 20+: For Vite and React.
- Docker: To run Keycloak and PostgreSQL.
-
Dependencies: Spring Boot starters for
web
,security
,oauth2-client
,oauth2-resource-server
, andthymeleaf
.
Setting Up Keycloak with PostgreSQL
We’ll deploy Keycloak and PostgreSQL using Docker Compose for a persistent database setup:
# docker-compose.yaml
services:
postgres:
image: postgres:latest # Official PostgreSQL image
environment:
POSTGRES_USER: postgres # Database username
POSTGRES_PASSWORD: mysecretpassword # Database password
POSTGRES_DB: keycloak # Database name for Keycloak
ports:
- "5432:5432" # Expose PostgreSQL port
volumes:
- postgres_data:/var/lib/postgresql/data # Persist data
networks:
- database # Connect to custom network
keycloak:
image: quay.io/keycloak/keycloak:latest # Latest Keycloak image
environment:
KEYCLOAK_ADMIN: admin # Admin username for Keycloak
KEYCLOAK_ADMIN_PASSWORD: admin # Admin password
KC_HTTP_ENABLED: true # Enable HTTP access
KC_DB: postgres # Use PostgreSQL as the database
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak # Database URL
KC_DB_USERNAME: postgres # Database username
KC_DB_PASSWORD: mysecretpassword # Database password
ports:
- "8088:8080" # Map host port 8088 to container port 8080
command:
- start-dev # Run in development mode
depends_on:
- postgres # Ensure PostgreSQL starts first
networks:
- database # Connect to custom network
volumes:
postgres_data: # Named volume for PostgreSQL data persistence
networks:
database:
driver: bridge # Use bridge networking
name: database # Network name
Run docker-compose up
to start Keycloak at http://localhost:8088
and PostgreSQL at localhost:5432
. Log in with admin/admin
, create a realm called my-realm
, and configure two clients:
- spring-boot-app: A confidential client for Spring Boot.
- react-app: A public client for React.
Create a realm role USER
and assign it to a test user (e.g., username: testuser
, password: password
) to enable unified login.
Keycloak Client Configurations
-
spring-boot-app:
- Client ID:
spring-boot-app
- Client Authentication: On (confidential)
- Valid Redirect URIs:
http://localhost:8081/*
- Valid Post Logout Redirect URIs:
http://localhost:8081/login?logout
- Web Origins:
*
- Client ID:
-
react-app:
- Client ID:
react-app
- Client Authentication: Off (public)
- Valid Redirect URIs:
http://localhost:5173/*
- Valid Post Logout Redirect URIs:
http://localhost:5173/*
- Web Origins:
*
- Client ID:
Spring Boot Backend Setup
The backend combines a Thymeleaf web UI with session-based OAuth2 and a REST API secured with JWT, powered by Keycloak.
Dependencies
In pom.xml
, include:
<dependencies>
<!-- Core web functionality -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Security framework -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 client for session-based login -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- OAuth2 resource server for JWT validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Thymeleaf for server-side rendering -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
Application Configuration
Configure Keycloak in application.yml
:
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: spring-boot-app # Client ID for Spring Boot
client-secret: oFKSAvp334RG5oTQwjlmS3LJNSNkvMTN # Client secret
scope: openid,profile,email # Requested scopes
provider:
keycloak:
issuer-uri: http://localhost:8088/realms/my-realm # Keycloak realm URL
server:
port: 8081 # Spring Boot runs on port 8081
Breakdown
-
spring.security.oauth2.client
: Configures the OAuth2 client for session-based login. -
client-id
andclient-secret
: Credentials forspring-boot-app
. -
issuer-uri
: Keycloak’s realm endpoint for OAuth2 discovery. -
server.port
: Runs the app on8081
to avoid conflicts.
Security Configuration
The updated SecurityConfig
class defines two filter chains with explicit endpoint matching:
package com.mahmud.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import java.util.Arrays;
@Configuration
public class SecurityConfig {
private final ClientRegistrationRepository clientRegistrationRepository; // Repository for OAuth2 client registrations
public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
this.clientRegistrationRepository = clientRegistrationRepository;
}
// Filter chain for session-based web UI
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher(
"/login", // Custom login page
"/login/oauth2/*/**", // OAuth2 callback endpoints
"/oauth2/*/**", // Additional OAuth2 paths
"/home", // Protected home page
"/logout", // Logout endpoint
"/public" // Public page
)
.cors(cors -> cors.configurationSource(request -> { // CORS configuration
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("http://localhost:5173")); // Allow React origin
config.setAllowedMethods(Arrays.asList("GET", "POST")); // Allowed HTTP methods
config.setAllowedHeaders(Arrays.asList("Authorization")); // Allow Authorization header
config.setAllowCredentials(false); // No credentials for simplicity
return config;
}))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/public").permitAll() // Public access to these endpoints
.anyRequest().authenticated() // All other endpoints require auth
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login") // Custom login page
.defaultSuccessUrl("/home", true) // Redirect after login
)
.logout(logout -> logout
.logoutUrl("/logout") // Logout endpoint
.logoutSuccessHandler(oidcLogoutSuccessHandler()) // Handle logout with Keycloak
.invalidateHttpSession(true) // Clear session
.clearAuthentication(true) // Clear auth context
);
return http.build();
}
// Filter chain for JWT-based API
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/secured") // Apply to /secured endpoint only
.cors(cors -> cors.configurationSource(request -> { // CORS configuration
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("http://localhost:5173")); // Allow React origin
config.setAllowedMethods(Arrays.asList("GET", "POST")); // Allowed methods
config.setAllowedHeaders(Arrays.asList("Authorization")); // Allow Authorization header
return config;
}))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated() // Require authentication
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder())) // Validate JWT with custom decoder
);
return http.build();
}
// Custom logout handler for OAuth2 logout with Keycloak
private LogoutSuccessHandler oidcLogoutSuccessHandler() {
OidcClientInitiatedLogoutSuccessHandler logoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
logoutSuccessHandler.setPostLogoutRedirectUri("http://localhost:8081/login?logout"); // Redirect after logout
return logoutSuccessHandler;
}
// JWT decoder to validate tokens from Keycloak
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("http://localhost:8088/realms/my-realm/protocol/openid-connect/certs").build();
}
}
Breakdown
-
webSecurityFilterChain
: Secures Thymeleaf endpoints withoauth2Login
. UpdatedsecurityMatcher
includes OAuth2 callback paths (/login/oauth2/*/**
,/oauth2/*/**
) for proper redirect handling. -
apiSecurityFilterChain
: Secures/secured
withoauth2ResourceServer
for JWT validation. -
cors
: Allows React athttp://localhost:5173
to access endpoints, supportingAuthorization
headers. -
jwtDecoder
: Validates JWTs using Keycloak’s JWKS endpoint. -
oidcLogoutSuccessHandler
: Manages logout with Keycloak integration.
Web Controller (Thymeleaf)
package com.mahmud.backend.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class WebController {
@GetMapping("/login")
public String login() {
return "login"; // Returns the login Thymeleaf template
}
@GetMapping("/public")
public String publicPage(Model model) {
model.addAttribute("message", "This is a public page!"); // Adds message to the model
return "public"; // Returns the public Thymeleaf template
}
@GetMapping("/home")
public String home(Model model, @AuthenticationPrincipal OidcUser user) {
model.addAttribute("username", user.getPreferredUsername()); // Adds username from OIDC user
return "home"; // Returns the home Thymeleaf template
}
}
Breakdown
-
login
: Serves a login page linking to Keycloak’s OAuth2 flow. -
publicPage
: A publicly accessible page with a message. -
home
: A protected page displaying the authenticated user’s username.
API Controller (JWT)
package com.mahmud.backend.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class DemoController {
@GetMapping("/secured")
public Map<String, String> securedMethod(@AuthenticationPrincipal Jwt jwt) {
Map<String, String> map = new HashMap<>();
map.put("message", "this is a secure message"); // Response message
map.put("username", jwt.getClaimAsString("preferred_username")); // Username from JWT
return map; // Returns JSON response
}
}
Breakdown
-
securedMethod
: A REST endpoint secured with JWT, returning a message and username from the token.
Thymeleaf Templates
-
login.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Login</title></head>
<body>
<h1>Login</h1>
<!-- Link to initiate Keycloak OAuth2 login -->
<a href="/oauth2/authorization/keycloak">Login with Keycloak</a>
<!-- Display logout message if present -->
<p th:if="${param.logout}">You have been logged out.</p>
</body>
</html>
-
public.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Public Page</title></head>
<body>
<h1>Public Page</h1>
<!-- Display message from the model -->
<p th:text="${message}"></p>
<!-- Link to login page -->
<a href="/login">Go to Login</a>
</body>
</html>
-
home.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Home</title></head>
<body>
<!-- Display welcome message with username -->
<h1>Welcome, <span th:text="${username}"></span>!</h1>
<!-- Logout form -->
<form th:action="@{/logout}" method="post">
<button type="submit">Logout</button>
</form>
</body>
</html>
Breakdown
-
login.html
: Links to Keycloak’s OAuth2 login flow. -
public.html
: Displays a public message with a login option. -
home.html
: Shows the username and a logout button for authenticated users.
React Frontend Setup
The React frontend uses Vite, Tailwind CSS 4, DaisyUI 5 (beta), and keycloak-js
.
Project Initialization
- Create the React App with Vite:
npm create vite@latest keycloak-react --template react
cd keycloak-react
npm install
- Install Tailwind CSS 4:
npm install -D tailwindcss @tailwindcss/vite
Update vite.config.js
:
// vite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(), // Integrate Tailwind CSS with Vite
],
});
- Install DaisyUI 5 (Beta):
npm install -D daisyui@beta
Update src/index.css
:
/* src/index.css */
@import "tailwindcss"; // Import Tailwind CSS
@plugin "daisyui"; // Add DaisyUI as a plugin
- Install Additional Dependencies:
npm install keycloak-js react-router-dom
Keycloak Initialization
In src/keycloak.js
:
// src/keycloak.js
import Keycloak from "keycloak-js";
// Initialize Keycloak instance with configuration
const keycloak = new Keycloak({
url: "http://localhost:8088/", // Keycloak server URL
realm: "my-realm", // Realm name
clientId: "react-app", // Client ID for React
});
export default keycloak;
Breakdown
-
keycloak
: Configureskeycloak-js
with thereact-app
client settings.
Main Entry (main.jsx
)
// src/main.jsx
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Public from './pages/Public.jsx';
// Define routes for the app
const router = createBrowserRouter([
{ path: '/', element: <App /> }, // Root route to App
{ path: '/public', element: <Public /> } // Public page route
]);
// Render the app with RouterProvider
createRoot(document.getElementById('root')).render(
<RouterProvider router={router} />
);
Breakdown
-
router
: Sets up routing withreact-router-dom
. -
createRoot
: Renders the React app.
App Component (App.jsx
)
// src/App.jsx
import { useEffect, useState } from "react";
import keycloak from "./keycloak";
const App = () => {
const [authenticated, setAuthenticated] = useState(false); // Track authentication status
const [data, setData] = useState(null); // Store API response
useEffect(() => {
// Initialize Keycloak and require login
keycloak
.init({ onLoad: "login-required" })
.then((authenticated) => {
setAuthenticated(authenticated);
if (authenticated) {
// Fetch secured endpoint with JWT
fetch("http://localhost:8081/secured", {
headers: {
Authorization: `Bearer ${keycloak.token}`, // Attach JWT
},
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json(); // Parse JSON response
})
.then((d) => setData(d)) // Set response data
.catch((err) => console.error("Fetch error:", err));
}
})
.catch((err) => console.error("Keycloak init error:", err));
}, []);
// Handle logout with redirect
const handleLogout = () => {
keycloak.logout({ redirectUri: "http://localhost:5173/public" });
};
if (!authenticated) {
return <div className="text-center mt-10">Loading...</div>; // Loading state
}
return (
<div className="min-h-screen bg-base-200 p-4">
{/* Welcome message with Tailwind and DaisyUI styling */}
<h1 className="text-3xl font-bold text-center mb-4">
Welcome, {keycloak.tokenParsed?.preferred_username}
</h1>
{/* Logout button */}
<button onClick={handleLogout} className="btn btn-secondary mb-4">
Logout
</button>
<div className="card bg-base-100 shadow-xl p-4">
<h3 className="text-xl font-semibold">Response from back-end:</h3>
{data ? (
<div>
<h1 className="text-2xl mt-2">{data.message}</h1>
<p className="mt-2">Username: {data.username}</p>
</div>
) : (
<p className="mt-2">Loading data...</p>
)}
</div>
</div>
);
};
export default App;
Breakdown
-
useEffect
: Forces login via Keycloak and fetches/secured
with JWT. -
handleLogout
: Logs out and redirects to/public
. - UI: Uses Tailwind CSS 4 and DaisyUI 5 for a responsive card layout.
Public Page (pages/Public.jsx
)
// src/pages/Public.jsx
const Public = () => {
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center">
<div className="card bg-base-100 shadow-xl p-6">
<h1 className="text-2xl font-bold">Public Page</h1>
<p className="mt-2">This is a public page accessible to all.</p>
</div>
</div>
);
};
export default Public;
Breakdown
-
Public
: A styled public page using Tailwind and DaisyUI components.
How It Works
-
Session-Based Web UI:
-
http://localhost:8081/login
triggers Keycloak’s OAuth2 login. - Post-login,
/home
uses a session cookie to display the username. - Logout redirects to
/login?logout
.
-
-
JWT-Based React Frontend:
- React forces login via
keycloak-js
and fetches/secured
with a JWT. - Displays the response in a styled UI.
- React forces login via
-
Unified Credentials:
- Both clients share the
my-realm
realm andUSER
role, allowingtestuser/password
to work across the board.
- Both clients share the
Testing the Integration
-
Start Keycloak and PostgreSQL:
docker-compose up
. -
Start Spring Boot:
mvn spring-boot:run
. -
Start React:
npm run dev
(runs onhttp://localhost:5173
). -
Test:
-
http://localhost:8081/public
: Public Thymeleaf page. -
http://localhost:8081/home
: Protected Thymeleaf page. -
http://localhost:5173/
: React app with secured API data.
-
Conclusion
This integration harnesses Keycloak’s versatility to unify authentication across Spring Boot with Thymeleaf and React with Tailwind CSS 4 and DaisyUI 5 (beta). By splitting security into session-based and JWT-based flows with precise endpoint matching, we cater to diverse client needs seamlessly. For production, secure with HTTPS and refine CORS settings. This setup provides a robust foundation for modern full-stack applications.
Top comments (3)
Seamless authentication is a game-changer! 🔐 If you're exploring authentication solutions, you might also find Logar useful – an open-source log management tool built with Next.js, Tailwind, ShadCN, Clerk, and Supabase.
Check it out here:
🔗 Live: logar-app.netlify.app
💻 GitHub: github.com/HardikGohilHLR/logar
Would love to hear your thoughts! 🚀
Good one there @olymahmud 🔥❤️🔥
Thank you 🤍 @bansikah