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.

Syntaxhttp.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

  1. 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 the ADMIN role, everything else under /api needs a valid token.
  2. Method rules with @PreAuthorize on the room service, so the rule still holds if a second endpoint or a scheduled job calls it.
  3. Ownership in the booking service: load the booking, compare its owner with the token's subject, and throw BookingNotFoundException on 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; sub is stable for the user at that issuer.
  • Trusting a userId in 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-Authenticate header and an empty body.

Example

Example · java
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;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    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.

Example · java
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.

Example · java
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.

Example · yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${OIDC_ISSUER_URI}
          audiences:
            - bookings-api

Discussion

  • Be the first to comment on this lesson.