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

5

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

nextjs tutorial video

Youtube Tutorial Series 📺

So you built a Next.js app, but you need a clear view of the entire operation flow to be able to identify performance bottlenecks before you launch. But how do you get started? Get the essentials on tracing for Next.js from @nikolovlazar in this video series 👀

Watch the Youtube series