Skip to main content

Keycloak with Spring Boot – Full Course | OAuth2, JWT & Role-Based Security

Every serious backend eventually has to answer two questions for every request: who is this? and what are they allowed to do? You can build login, password reset, token issuing, SSO and role management yourself, but it is hard, slow and very easy to get wrong. Or you can hand the whole job to Keycloak and keep your Spring Boot code focused on the business logic.

This article is the written companion to my full Keycloak + Spring Boot course on YouTube. We go from zero to a working, role-secured REST API: install Keycloak, set up a realm, client, roles and users, move Keycloak off its default H2 database onto PostgreSQL, test the OpenID Connect endpoints in Postman, and finally protect Spring Boot endpoints with JWTs and Spring Security.

Source code: All the code is on GitHub: sprashantofficial/KeycloakSpringBootApp. The complete course is also on Udemy.

What We Are Building

Two servers run side by side: Keycloak on localhost:8080 and our Spring Boot API on localhost:8082. Keycloak has one realm (codingcenter), one client for our app, two roles (employee and manager) and two users:

UserRoleCan call
John Doemanager/api/v1 and /api/v1/manager
Jane Doeemployee/api/v1 and /api/v1/employee

The API exposes three endpoints. Any valid token can call the common one; the other two check the role inside the token:

EndpointToken from John (manager)Token from Jane (employee)
GET /api/v1200 – Hello!200 – Hello!
GET /api/v1/manager200 – Hello from Admin!!403 Forbidden
GET /api/v1/employee403 Forbidden200 – Hello from User!!

On top of that we build a Postman collection for the Keycloak side: login, logout, logout validation and guest login, all driven by environment variables so you never copy-paste a token by hand.

Why Do We Need Keycloak?

Picture a big party with a gaming room, a movie room, a food room and a VIP lounge. You don't want guests proving they're invited at every single door. So you hire one bouncer at the front door. He checks your ID once and stamps your hand. With that stamp you walk into every room, and when you reach the VIP lounge the bouncer only has to check whether your stamp says VIP.

Keycloak is that bouncer, and the stamp is a token. In technical terms, Keycloak is an open-source identity and access management (IAM) solution that gives you:

  • Authentication (who you are): username/password, social logins and multi-factor authentication, with ready-made login pages.
  • Authorization (what you can do): roles, permissions and access policies. An admin can edit users; a regular user cannot.
  • Single Sign-On: log in once, use many apps, the way one Google login opens Gmail, Drive and Calendar. Logout works the same way: log out once and you are out everywhere.
  • Federation: plug into identity systems you already have, such as LDAP, Active Directory, Google or SAML providers.
  • Token-based access: secure JWTs that APIs can verify without keeping sessions. This is the part our Spring Boot API uses.
  • Standards: OAuth 2.0, OpenID Connect and SAML, plus an admin console and REST APIs.

It is also widely used in production. Red Hat, Siemens, CERN, Deutsche Bank, EU institutions, the Brazilian government, the UK's NHS and Novartis all run it. The reasons are usually the same: it is free and open source, SSO/MFA/OAuth2 work out of the box, and it scales well with microservices and Kubernetes.

Tokens vs Federation

These two often get mixed up:

  • A token is a passport. After login, Keycloak gives your app a JWT that proves who the user is and what they can do. Your app can verify it on its own, without calling Keycloak on every request.
  • Federation is a trust agreement. Keycloak trusts another system (Google, Microsoft, your company's LDAP) to do the authentication, so users sign in with credentials they already have instead of creating new accounts in Keycloak.

In a typical enterprise setup, you sign in once through Keycloak (which may delegate to Azure AD or LDAP behind the scenes). Keycloak then issues a token, and every downstream application reads it to decide what you are allowed to access.

The Four Building Blocks

ConceptWhat it isIn our project
RealmAn isolated container for users, credentials, roles and groups. Realms share nothing with each other. Think of separate castles in a kingdom.codingcenter
ClientAn application or service that asks Keycloak to authenticate users (web app, mobile app or API).Our Spring Boot app
RoleA responsibility or permission you assign to users.employee, manager
UserA person (or account) who logs in.John Doe, Jane Doe

Step 1: Install Keycloak and Start It in Dev Mode

Keycloak needs OpenJDK 21, so install that first. Then go to the official Keycloak site, click Download, and grab the zip or tar.gz for your OS (the contents are the same either way). Extract it and you'll see these folders:

  • bin: holds kc.sh (Linux/macOS) and kc.bat (Windows), the scripts that run Keycloak.
  • conf: holds keycloak.conf, which we will edit when we move to PostgreSQL.
  • lib, providers, themes: we won't touch these in this course.

From inside the Keycloak folder, start it in development mode:

Terminal
cd keycloak
bin/kc.sh start-dev        # Windows: bin\kc.bat start-dev

In the logs you'll see Profile dev activated and Keycloak listening on port 8080. Open http://localhost:8080, create the initial admin user (I used admin / admin for local work), then click Open Administration Console and sign in.

Note: Keycloak calls this a temporary admin and recommends creating a permanent admin user later to harden security. For local learning, the temporary one works fine and you can ignore the banner.

A Quick Tour of the Admin Console

  • Top-right menu: Manage account (profile, signing-in options, device activity, connected applications), realm info, clear caches, sign out.
  • Manage realms: you start in the built-in master realm.
  • Clients, Client scopes, Realm roles, Users, Groups: the building blocks from the table above.
  • Sessions and Events: active sessions, and an audit trail of user and admin events.
  • Realm settings: login options, email, themes, and the Endpoints link we use later.
  • Authentication, Identity providers, User federation: auth flows, social/OpenID/SAML logins, and LDAP/Active Directory integration.

Step 2: Create the Realm, Client, Roles and Users

Realm

Go to Manage realms → Create realm, name it codingcenter and click Create. The current realm switches from master to codingcenter. Do everything after this inside this realm.

Client

Go to Clients → Create client, give it an ID for the Spring Boot app (the repo uses springboot-app), click Next and set the capability config:

SettingValueWhy
Client authenticationONMakes it a confidential client with a secret. With this OFF the client is public.
AuthorizationONEnables fine-grained authorization. It also switches on service accounts, which the guest login (client credentials) relies on.
Standard flowON (default)OpenID Connect redirect-based login with an authorization code.
Direct access grantsONLets us exchange a username and password directly for tokens. This is what our Postman login request uses.

Leave the URLs on the next screen empty for now and click Save. Then open the client's Credentials tab. That is where your client secret is, and you'll need it in Postman and in application.properties.

Realm Roles

Under Realm roles → Create role, create employee, then create manager. Keep them lowercase, because the Spring code matches them exactly.

Users

Under Users → Add user, create John Doe (first name John, last name Doe, an email). Leave Email verified off, since this is for learning. Then:

  1. Credentials tab → Set password (I used john). Turn Temporary OFF, or the user is forced to change it on first login.
  2. Role mapping tab → Assign role → switch the filter from client roles to realm roles → pick manager.

Repeat for Jane Doe with password jane and role employee. To double-check, open a role and look at the Users in role tab.

Heads-up: Usernames can't be edited after creation. If you mistype one (I did in the video), delete the user and create it again.

Step 3: Move Keycloak from H2 to PostgreSQL

In dev mode, the startup log lists jdbc-h2 under installed features. Keycloak is using an embedded H2 database. That is why start-dev works with no setup at all.

H2 is like instant noodles: great for a quick bite, terrible as a daily diet. A crash or shutdown can corrupt the data, other applications can't share it, and upgrades can break it. PostgreSQL is the proper home-cooked meal. It takes a bit of setup, but it handles lots of users and data, accepts connections from other services, and comes with backups, restores, monitoring and GUI tooling. PostgreSQL 17 is also a version Keycloak officially lists as tested.

Install PostgreSQL and Create the Database

  1. Download the installer for your OS from the official PostgreSQL site. On macOS you can also use Homebrew.
  2. Select the PostgreSQL Server, pgAdmin 4 (the GUI) and Stack Builder components. Command Line Tools are optional.
  3. Set a password for the superuser postgres and remember it. Keep the default port 5432.
  4. Open pgAdmin 4 and create an empty database called keycloak.

Point Keycloak at PostgreSQL

Open conf/keycloak.conf, uncomment the db line, and add the connection settings:

conf/keycloak.conf
# Database
db=postgres

# PostgreSQL connection settings
db-url=jdbc:postgresql://localhost:5432/keycloak
db-username=postgres
db-password=<your-postgres-password>

# Optional: log level
log-level=info

# HTTP (local only - see the note below)
http-enabled=true
hostname-strict=false

Now start Keycloak in production mode, using start instead of start-dev:

Terminal
bin/kc.sh start

# Review what Keycloak picked up from keycloak.conf
bin/kc.sh show-config

# After the first build, Keycloak suggests this for later runs
bin/kc.sh start --optimized

On the first run, Keycloak detects the configuration change, rebuilds its server image and persists the new configuration. I hit two errors along the way in the video, and you may too:

ErrorCauseFix
Key material not provided to setup HTTPSProduction mode expects HTTPS and disables HTTP by default.Add http-enabled=true and hostname-strict=false to keycloak.conf (for local use only).
password authentication failed for user ...Wrong value in db-username or db-password.Use the real PostgreSQL superuser (postgres) and its password.
Heads-up: http-enabled=true with hostname-strict=false is fine on your laptop. On a real server, configure TLS certificates and a proper hostname instead.

Once Keycloak is listening, refresh the keycloak database in pgAdmin. You'll find around 88 tables that Keycloak created on its own. Because this is a fresh database, Keycloak asks you to create the admin user again, and you need to recreate the realm, client, roles and users as well. After that, query user_entity and John Doe and Jane Doe are there, now stored in PostgreSQL.

Step 4: Test the OpenID Connect Endpoints in Postman

Go to Realm settings, scroll down to Endpoints, and open OpenID Endpoint Configuration. Paste the JSON into any formatter and you'll see the endpoints we care about: token_endpoint, authorization_endpoint, introspection_endpoint, userinfo_endpoint and end_session_endpoint.

Collection and Environment Variables

In Postman, create a collection (mine is Spring Boot Keycloak Demo) and an environment (Spring Boot Keycloak Env) with these variables, so no URL or secret is hard-coded:

VariableValue
keycloak_urlhttp://localhost:8080
realm_namecodingcenter
client_idYour client ID from Keycloak
client_secretFrom the client's Credentials tab
access_tokenEmpty: filled automatically by the login request (Step 6)
refresh_tokenEmpty: filled automatically by the login request (Step 6)

Reference a variable as {{keycloak_url}}. When it turns blue/green, Postman has resolved it from the selected environment.

1. Login (Token Endpoint)

Request
POST {{keycloak_url}}/realms/{{realm_name}}/protocol/openid-connect/token
Body key (x-www-form-urlencoded)Value
usernameThe username you created for John Doe
passwordjohn
grant_typepassword
client_id{{client_id}}
client_secret{{client_secret}}

Send it and you get 200 OK with both tokens:

Response (trimmed)
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6...",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6...",
  "token_type": "Bearer",
  "scope": "profile email"
}

Access Token vs Refresh Token

Access tokenRefresh token
LifetimeShort: 300 s (5 min)Longer: 1800 s (30 min)
Used forCalling protected APIs, sent as Authorization: Bearer ...Getting a new access token without logging in again
ContainsUser info, roles and permissions (JWT)A handle to the user's Keycloak session
WhyIf stolen, it is only useful for a few minutesKeeps background sessions alive

2. Logout (End Session Endpoint)

Request
POST {{keycloak_url}}/realms/{{realm_name}}/protocol/openid-connect/logout
  • Authorization: Bearer Token → {{access_token}}
  • Body: client_id, client_secret, refresh_token (the latest, unexpired one)

A successful logout returns 204 No Content with an empty body.

3. Logout Validation

How do you prove the logout actually worked? Duplicate the login request and try to use the refresh token you just logged out with:

Body keyValue
grant_typerefresh_token
refresh_tokenThe refresh token from before the logout
client_id{{client_id}}
client_secret{{client_secret}}

Before logout, this returns a fresh access token. After logout, it returns 400 Bad Request with invalid_grant / Session not active. The session is gone, so the old refresh token is useless.

4. Guest Login (Client Credentials)

What about a caller who has no user account in Keycloak? There is no username or password, and no token to send. Here the client itself authenticates:

  • Authorization: Basic Auth → username = client ID, password = client secret
  • Body: grant_type = client_credentials

You get 200 OK with an access token but no refresh token. When it expires, the caller simply requests a new one.

Step 5: Build the Spring Boot Resource Server

Generate the Project

On Spring Initializr, pick:

OptionValue
Project / LanguageMaven / Java
Spring Boot4.x
Group / Artifactcom.example / KeycloakSpringBootApp
Java21 (25 works too)
DependenciesSpring Web, Spring Security, OAuth2 Resource Server

Why Resource Server and not the other two OAuth2 options?

  • Authorization Server issues tokens. Keycloak already does that, so we don't need it.
  • OAuth2 Client calls other APIs using a token it obtained. That isn't our job here.
  • Resource Server hosts APIs that require tokens. That is exactly what we are building. It validates JWTs automatically using Keycloak's public keys and plugs straight into Spring Security, so you never parse or verify a JWT by hand.

application.properties

Spring Boot and Keycloak both default to port 8080, so the app moves to 8082. The repo uses realm codingcenter:

src/main/resources/application.properties
# ===============================
# Application Name and Port
# ===============================
spring.application.name=keycloakdemo
server.port=8082

# ===============================
# Keycloak OAuth2 Client (if you want to support login flows)
# ===============================
spring.security.oauth2.client.registration.keycloak.client-id=springboot-app
spring.security.oauth2.client.registration.keycloak.client-secret=<your-client-secret>
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirect-uri=http://localhost:8082/login/oauth2/code/keycloak
spring.security.oauth2.client.registration.keycloak.provider=keycloak

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/codingcenter

# ===============================
# OAuth2 Resource Server (API Token Validation)
# ===============================
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/codingcenter
# Alternatively:
# spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8080/realms/codingcenter/protocol/openid-connect/certs

# ===============================
# Logging (for Debugging)
# ===============================
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web=INFO

What each block does:

  • OAuth2 Client: client ID and secret (your app's identity and password in Keycloak), scopes (openid, profile, email), the authorization-code browser flow, the redirect URI Keycloak returns to after login, and issuer-uri, which tells the app where Keycloak lives.
  • Resource Server: the property that actually protects the API. Every incoming Authorization: Bearer <jwt> is validated against this issuer. Spring checks the token's iss claim and auto-discovers the JWKS endpoint for the signing keys. The commented-out jwk-set-uri is the alternative: it skips discovery and points straight at the keys.
  • Logging: DEBUG for Spring Security, so you can watch every auth decision while learning.
Note: Since the project only adds the Resource Server starter, the OAuth2 Client block is there for reference. It becomes active if you later add spring-boot-starter-oauth2-client for browser-based login. Token validation only needs the resource-server line.
Heads-up: Never commit a real client secret. Keep it in an environment variable, e.g. client-secret=${KEYCLOAK_CLIENT_SECRET}.

JwtConverter: Teaching Spring Security About Keycloak Roles

Keycloak puts realm roles inside a nested claim in the JWT:

Decoded access token (John Doe)
"realm_access": {
  "roles": [
    "manager",
    "offline_access",
    "uma_authorization",
    "default-roles-codingcenter"
  ]
}

Spring Security doesn't read realm_access.roles by default. Its hasRole(...) checks look for GrantedAuthority objects with a ROLE_ prefix. The converter bridges the two:

config/JwtConverter.java
package com.example.KeycloakSpringBootApp.config;

import org.jspecify.annotations.Nullable;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class JwtConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public @Nullable Collection<GrantedAuthority> convert(Jwt source) {
        Map<String, Object> realmAccess = source.getClaim("realm_access");
        if (realmAccess == null || realmAccess.isEmpty()) {
            return Collections.emptyList();
        }

        Collection<String> roles = (Collection<String>) realmAccess.get("roles");

        return roles.stream()
                .map(role -> "ROLE_" + role)
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

}
  • @Component registers it as a Spring bean so the security config can inject it.
  • It reads the realm_access claim as a Map. If the claim is missing or empty, it returns an empty list (a defensive check: no roles means no authorities).
  • It pulls the roles array, prefixes each role with ROLE_, wraps it in a SimpleGrantedAuthority and collects the results into a list. So manager becomes ROLE_manager.

SecurityConfig: The Bouncer at Your API's Door

config/SecurityConfig.java
package com.example.KeycloakSpringBootApp.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.beans.factory.annotation.Autowired;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private JwtConverter jwtConverter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // disable CSRF for REST APIs
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/public/**").permitAll()   // public APIs
                        .requestMatchers("/api/manager/**").hasRole("manager") // only manager
                        .requestMatchers("/api/employee/**").hasRole("employee") // only employee
                        .anyRequest().authenticated() // everything else requires auth
                )
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
                );

        return http.build();
    }

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwtConverter); // plug in custom Keycloak role converter
        return converter;
    }
}
  • @Configuration + @EnableWebSecurity turn on Spring Security for web requests; @EnableMethodSecurity enables @PreAuthorize on controller methods.
  • CSRF is disabled because this is a stateless REST API that uses bearer tokens. CSRF protection matters for browser forms and cookie sessions, not here.
  • authorizeHttpRequests defines URL rules: public paths are open, manager and employee paths need their role, and anyRequest().authenticated() means everything else needs a valid token.
  • oauth2ResourceServer(...jwt...) tells Spring that this app is a resource server and that it should validate incoming tokens as JWTs, running each one through our JwtConverter via a JwtAuthenticationConverter.
Worth knowing: Our endpoints live under /api/v1/..., so the /api/manager/** and /api/employee/** URL rules don't match them. Those requests fall through to anyRequest().authenticated(). The role check that produces the 403 in the demo is the method-level @PreAuthorize in the controller. If you prefer URL-based rules, change the matchers to /api/v1/manager/** and /api/v1/employee/**. Keeping both layers is a reasonable defense-in-depth choice.

Controller

controller/Controller.java
package com.example.KeycloakSpringBootApp.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1")
public class Controller {

    @GetMapping
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("Hello!");
    }

    @GetMapping("/employee")
    @PreAuthorize("hasRole('employee')")
    public ResponseEntity<String> helloUser() {
        return ResponseEntity.ok("Hello from User!!");
    }

    @GetMapping("/manager")
    @PreAuthorize("hasRole('manager')")
    public ResponseEntity<String> helloAdmin() {
        return ResponseEntity.ok("Hello from Admin!!");
    }

}
  • @RequestMapping("/api/v1") gives every endpoint a common versioned prefix.
  • GET /api/v1 has no role check, so any valid token from any user works.
  • GET /api/v1/employee and GET /api/v1/manager use @PreAuthorize("hasRole(...)"). Spring adds the ROLE_ prefix itself and compares the result with the authorities our converter built.

Step 6: End-to-End Validation

Start the Spring Boot app, log in as a user in Postman, then call each endpoint with Authorization → Bearer Token:

Logged in asEndpointResult
John (manager)GET /api/v1200 – Hello!
Jane (employee)GET /api/v1200 – Hello!
Jane (employee)GET /api/v1/manager403 Forbidden
Jane (employee)GET /api/v1/employee200 – Hello from User!!
John (manager)GET /api/v1/employee403 Forbidden
John (manager)GET /api/v1/manager200 – Hello from Admin!!
Note: Got a 404 on the first call? Check the path. My first attempt had an extra /v1 in the URL. Got a connection error? The Spring Boot app isn't running yet.

Stop Copy-Pasting Tokens: Postman Post-Response Script

Open the Login request → Scripts → Post-response and add:

Login → Scripts → Post-response
var jsonData = pm.response.json();
pm.environment.set("access_token", jsonData.access_token);
pm.environment.set("refresh_token", jsonData.refresh_token);

Now every login writes fresh tokens into access_token and refresh_token. Set every other request to use {{access_token}} or {{refresh_token}}. Switching between John and Jane then takes one click on Send, with no copy-pasting.

Recap

  1. Installed Keycloak and ran it in dev mode on the embedded H2 database.
  2. Created the codingcenter realm, a confidential client, employee/manager roles and two users.
  3. Moved Keycloak to PostgreSQL and ran it in production mode.
  4. Tested login, logout, logout validation and guest login against the OpenID Connect endpoints in Postman.
  5. Built a Spring Boot 4 resource server that validates Keycloak JWTs, maps realm roles to Spring authorities and enforces role-based access per endpoint.

From here, the natural next steps are refresh-token rotation in a real client, client roles instead of realm roles, and running Keycloak in Docker or Kubernetes behind TLS.

Resources

If this helped, subscribe on YouTube and drop your questions in the comments. I read all of them.

Comments