Securing the API
Make the bookings API an OAuth2 resource server: verify JWTs, map a roles claim to authorities, and enforce ownership in the service.
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/rooms/**").permitAll()
.requestMatchers("/api/rooms/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(...))The bookings API does not handle passwords or sign-in. An identity provider such as Keycloak, Auth0, Entra ID or Cognito issues access tokens, and the API only checks them. That keeps credentials, password resets and multi-factor login out of your code, and lets several services trust the same tokens.
What the resource server does
With spring-boot-starter-oauth2-resource-server and spring.security.oauth2.resourceserver.jwt.issuer-uri set, Spring Boot fetches the provider's public keys, then verifies each Authorization: Bearer token's signature, expiry and issuer. Setting audiences as well rejects tokens issued for a different API by the same provider, a check that is easy to forget and that matters as soon as one provider serves several applications.
Three levels of authorisation
- URL rules in the
SecurityFilterChain: the health, info and Prometheus endpoints (served only on the private management port) and room listing are public, adding rooms needs theADMINrole, everything else under/apineeds a valid token. - Method rules with
@PreAuthorizeon the room service, so the rule still holds if a second endpoint or a scheduled job calls it. - Ownership in the booking service: load the booking, compare its owner with the token's subject, and throw
BookingNotFoundExceptionon a mismatch. This is data-level authorisation, and no annotation knows your data model as well as the query does.
Roles from the token
By default Spring Security turns the scope claim into SCOPE_ authorities. This project's provider puts roles in a roles claim, so a JwtAuthenticationConverter reads that claim and adds the ROLE_ prefix, which makes hasRole("ADMIN") work. Check a real token from your provider before writing this: claim names differ, and some nest roles inside another object.
Stateless means no CSRF tokens
The API never creates a session and authenticates every request from the Authorization header, which a browser does not attach automatically. That is why CSRF protection can be switched off here. If you ever add cookie-based sign-in to the same application, turn it back on for those routes.
Mistakes worth avoiding
- Using the email claim as the owner ID. Emails change;
subis stable for the user at that issuer. - Trusting a
userIdin the request body or path. The owner always comes from the verified token. - A rule order where a broad matcher comes first. Rules are checked top to bottom, so
/api/**before/api/rooms/**would make the admin rule unreachable. - Expecting Problem Details for 401 and 403 from the filter chain. Those are written by Spring Security before MVC runs, with a
WWW-Authenticateheader and an empty body.
Example
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
public class SecurityConfig {
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Actuator only listens on the private management port (8081).
.requestMatchers("/actuator/health/**", "/actuator/info", "/actuator/prometheus").permitAll()
.requestMatchers(HttpMethod.GET, "/api/rooms/**").permitAll()
.requestMatchers("/api/rooms/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(rolesFromToken())))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
// {"sub": "user-42", "roles": ["ADMIN"]} becomes the authority ROLE_ADMIN.
private JwtAuthenticationConverter rolesFromToken() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}
}
When to use it
- Let staff sign in through the company's identity provider and book rooms, without the API ever handling a password.
- Allow only facilities managers, who carry the ADMIN role in their token, to add or rename rooms.
- Stop a user from cancelling a colleague's booking by guessing its ID, and give them no hint that it exists.
More examples
Ownership checks in the booking service
Every method that takes a booking ID goes through <code>ownedBooking</code>, so a new endpoint cannot forget the check. The subject is passed in from the controller, which reads it from the verified token; the service never trusts an owner value from the request body. Keeping the check in the service also means the unit tests can prove it without any security setup.
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// Trimmed copies of earlier types, so this file compiles on its own.
class Booking {
Long id; String ownerId; String roomName; Instant startsAt; Instant endsAt; String status = "CONFIRMED";
boolean isOwnedBy(String subject) { return ownerId.equals(subject); }
void cancel(Instant now) { status = "CANCELLED"; }
}
interface BookingRepository { Optional<Booking> findWithRoomById(long id); }
record BookingView(long id, String roomName, Instant startsAt, Instant endsAt, String status) {
static BookingView of(Booking b) { return new BookingView(b.id, b.roomName, b.startsAt, b.endsAt, b.status); }
}
class BookingNotFoundException extends RuntimeException {
BookingNotFoundException(long id) { super("Booking " + id + " was not found."); }
}
@Service
public class BookingService {
private final BookingRepository bookings;
private final Clock clock;
BookingService(BookingRepository bookings, Clock clock) {
this.bookings = bookings;
this.clock = clock;
}
@Transactional(readOnly = true)
public BookingView get(String subject, long id) {
return BookingView.of(ownedBooking(subject, id));
}
@Transactional
public BookingView cancel(String subject, long id) {
Booking booking = ownedBooking(subject, id);
booking.cancel(Instant.now(clock));
return BookingView.of(booking); // the change is flushed at commit
}
// Someone else's booking looks exactly like one that does not exist.
private Booking ownedBooking(String subject, long id) {
return bookings.findWithRoomById(id)
.filter(b -> b.isOwnedBy(subject))
.orElseThrow(() -> new BookingNotFoundException(id));
}
}
Admin-only room management with @PreAuthorize
The URL rule already blocks non-admins from <code>POST /api/rooms</code>; <code>@PreAuthorize</code> is the second lock on the door that matters, the operation itself. When it fails, Spring throws <code>AuthorizationDeniedException</code> and the response is a 403. It needs <code>@EnableMethodSecurity</code>, which is on <code>SecurityConfig</code>; without it the annotation is silently ignored.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Entity
class Room {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String name;
int capacity;
protected Room() { }
Room(String name, int capacity) { this.name = name; this.capacity = capacity; }
}
interface RoomRepository extends JpaRepository<Room, Long> { }
record CreateRoomRequest(@NotBlank String name, @Positive @Max(500) int capacity) { }
record RoomView(long id, String name, int capacity) { }
@Service
public class RoomService {
private final RoomRepository rooms;
RoomService(RoomRepository rooms) {
this.rooms = rooms;
}
// Holds even if a batch import or another controller calls this method.
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public RoomView create(CreateRoomRequest request) {
Room room = rooms.save(new Room(request.name().trim(), request.capacity()));
return new RoomView(room.id, room.name, room.capacity);
}
}
Resource server configuration
<code>issuer-uri</code> is all Spring Boot needs to discover the provider's key set and check the <code>iss</code> claim. The audience list makes the API reject tokens minted for other clients of the same provider. Both come from the environment, so each stage points at its own realm without a rebuild.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${OIDC_ISSUER_URI}
audiences:
- bookings-api
Discussion