DEV Community

Cover image for Seamless Authentication System: Integrating Keycloak with Spring Boot, Thymeleaf, and React Using OAuth2 and JWT
M. Oly Mahmud
M. Oly Mahmud

Posted on

Seamless Authentication System: Integrating Keycloak with Spring Boot, Thymeleaf, and React Using OAuth2 and JWT

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, and thymeleaf.

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 
Enter fullscreen mode Exit fullscreen mode

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: *

spring-boot-app client

spring-boot-app client

spring-boot-app client

  • 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: *

react-app client

react-app client

react-app client


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> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Breakdown

  • spring.security.oauth2.client: Configures the OAuth2 client for session-based login.
  • client-id and client-secret: Credentials for spring-boot-app.
  • issuer-uri: Keycloak’s realm endpoint for OAuth2 discovery.
  • server.port: Runs the app on 8081 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(); } } 
Enter fullscreen mode Exit fullscreen mode

Breakdown

  • webSecurityFilterChain: Secures Thymeleaf endpoints with oauth2Login. Updated securityMatcher includes OAuth2 callback paths (/login/oauth2/*/**, /oauth2/*/**) for proper redirect handling.
  • apiSecurityFilterChain: Secures /secured with oauth2ResourceServer for JWT validation.
  • cors: Allows React at http://localhost:5173 to access endpoints, supporting Authorization 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 } } 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode
  • 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> 
Enter fullscreen mode Exit fullscreen mode
  • 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> 
Enter fullscreen mode Exit fullscreen mode

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

  1. Create the React App with Vite:
 npm create vite@latest keycloak-react --template react cd keycloak-react npm install 
Enter fullscreen mode Exit fullscreen mode
  1. Install Tailwind CSS 4:
 npm install -D tailwindcss @tailwindcss/vite 
Enter fullscreen mode Exit fullscreen mode

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 ], }); 
Enter fullscreen mode Exit fullscreen mode
  1. Install DaisyUI 5 (Beta):
 npm install -D daisyui@beta 
Enter fullscreen mode Exit fullscreen mode

Update src/index.css:

 /* src/index.css */ @import "tailwindcss"; // Import Tailwind CSS @plugin "daisyui"; // Add DaisyUI as a plugin 
Enter fullscreen mode Exit fullscreen mode
  1. Install Additional Dependencies:
 npm install keycloak-js react-router-dom 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

Breakdown

  • keycloak: Configures keycloak-js with the react-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} /> ); 
Enter fullscreen mode Exit fullscreen mode

Breakdown

  • router: Sets up routing with react-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; 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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.
  • Unified Credentials:

    • Both clients share the my-realm realm and USER role, allowing testuser/password to work across the board.

Testing the Integration

  1. Start Keycloak and PostgreSQL: docker-compose up.
  2. Start Spring Boot: mvn spring-boot:run.
  3. Start React: npm run dev (runs on http://localhost:5173).
  4. 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)

Collapse
 
hardikgohilhlr profile image
Hardik Gohil

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! 🚀

Collapse
 
bansikah profile image
Tandap Noel Bansikah

Good one there @olymahmud 🔥❤️‍🔥

Collapse
 
olymahmud profile image
M. Oly Mahmud

Thank you 🤍 @bansikah