DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Issue and Present Verifiable Credentials With Spring Boot and Android
  • Scalable JWT Token Revocation in Spring Boot
  • Authentication With Remote LDAP Server in Spring Web MVC
  • How to Implement Two-Factor Authentication in a Spring Boot OAuth Server? Part 1: Configuration

Trending

  • Your Automation Pipeline Is Not a Source of Truth
  • Treat Your AI's Output Like User Input
  • Every SOC Today Is Answering the Wrong Question
  • Reducing CI Execution Time Using Impact-Based Test Selection Across Repositories
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Designing Secure REST APIs With Spring Boot

Designing Secure REST APIs With Spring Boot

Learn how to secure Spring Boot REST APIs with JWT validation, method-level authorization, input validation, rate limiting, CORS, secure logging, and more.

By 
Srivenkata Gantikota user avatar
Srivenkata Gantikota
·
Jul. 27, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
217 Views

Join the DZone community and get the full member experience.

Join For Free

Most Spring Boot APIs I’ve reviewed have a security configuration that was correct three commits ago. Then somebody added a new endpoint, the security config didn’t get the matching update, and now there’s an unauthenticated path under /api/internal/ that returns a JSON dump of every active user. The team didn’t intend it; the framework didn’t catch it; the SAST tool flagged it three weeks later when the next scan ran.

This article is a reference implementation. JWT authentication done correctly, rate limiting that survives distributed deployments, input validation that catches more than annotations alone, and output encoding that doesn’t break under the edge cases. Each section has the code that should be on every API by default, with the rationale for why.

The Baseline Security Configuration

Spring Security’s default behavior is to deny everything. That’s the right default. The configuration that follows opens up only what should be open and authenticates everything else.

Java
 
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           JwtDecoder jwtDecoder) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // For stateless JWT APIs
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/health", "/metrics").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.decoder(jwtDecoder))
            )
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; frame-ancestors 'none'"))
                .frameOptions(frame -> frame.deny())
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))
            );
        return http.build();
    }
}


A few specific decisions:

CSRF disabled. Only because we’re stateless and using bearer tokens. If your API uses session cookies, leave CSRF enabled. The vulnerability that CSRF protects against requires session-based authentication.

anyRequest().authenticated() at the end. The catch-all matters. Without it, an endpoint that doesn’t have an explicit rule defaults to permitted. With it, an endpoint without an explicit rule requires authentication. The latter is the safer default.

Security headers. CSP, frame-options, HSTS. These are the headers that mitigate browser-side attacks. Even an API that never serves to a browser benefits from them, because errors might leak through a browser at some point.

JWT Validation That Doesn’t Have Known Holes

JWT is widely deployed and widely misconfigured. The specific mistakes that show up in scans:

Accepting none algorithm. The JWT spec includes a none algorithm for unsigned tokens. If your decoder accepts it, an attacker can forge any token. Spring Security’s default JWT decoder doesn’t accept none, but if you’ve written a custom one, check.

Confusing HS256 and RS256. A symmetric algorithm (HS256) means the secret key both signs and verifies. An asymmetric algorithm (RS256) means a private key signs and a public key verifies. If your service is configured to verify with a key but accepts whichever algorithm the token specifies, an attacker can sign with the public key (treating it as a symmetric secret), and your service will accept it. Pin the algorithm explicitly.

Skipping signature verification. Yes, this happens. The decoder is supposed to verify; somebody disabled it during debugging; it never got re-enabled.

The configuration:

Java
 
@Bean
public JwtDecoder jwtDecoder(@Value("${jwt.issuer-uri}") String issuer,
                             @Value("${jwt.audience}") String expectedAudience) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withIssuerLocation(issuer)
        .jwsAlgorithm(SignatureAlgorithm.RS256) // Pin the algorithm
        .build();

    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
    OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>(
        JwtClaimNames.AUD,
        aud -> aud != null && aud.contains(expectedAudience));
    OAuth2TokenValidator<Jwt> withClockSkew = new JwtTimestampValidator(Duration.ofSeconds(30));

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        withIssuer, withAudience, withClockSkew));

    return decoder;
}


JwtClaimValidator ships with Spring Security and lets you assert a predicate against any claim. (Spring Boot 3.2+ also exposes a shortcut: set spring.security.oauth2.resourceserver.jwt.audiences in application properties and the framework wires up audience validation for you.) The audience check is what stops a token meant for one service from being used against another. If your identity provider issues tokens for several services, each service should require its own audience claim.

The clock skew (30 seconds) accounts for clock drift between the token issuer and your service. Tokens issued slightly in the future or expired slightly in the past are still accepted.

Authorization at the Method Level

Authentication is who you are. Authorization is what you can do. Spring’s method-level security is the place to express it.

Java
 
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{orderId}")
    @PreAuthorize("@orderAuthorization.canRead(#orderId, authentication)")
    public Order getOrder(@PathVariable String orderId) {
        return orderService.findById(orderId);
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public Order createOrder(@Valid @RequestBody CreateOrderRequest request,
                             Authentication authentication) {
        return orderService.create(request, authentication.getName());
    }

    @DeleteMapping("/{orderId}")
    @PreAuthorize("@orderAuthorization.canDelete(#orderId, authentication) " +
                  "and hasAuthority('SCOPE_orders:delete')")
    public void deleteOrder(@PathVariable String orderId) {
        orderService.delete(orderId);
    }
}


The @orderAuthorization reference is a custom bean that handles the data-dependent authorization. The reason for using a SpEL expression rather than putting the logic in the controller body: the authorization check happens before the controller method runs, which means the method body can assume authorization passed and doesn’t have to repeat the check.

The custom bean:

Java
 
@Component("orderAuthorization")
public class OrderAuthorization {

    private final OrderRepository orderRepo;

    public boolean canRead(String orderId, Authentication auth) {
        Order order = orderRepo.findById(orderId).orElse(null);
        if (order == null) return false;

        Jwt jwt = (Jwt) auth.getPrincipal();
        String userId = jwt.getSubject();

        // Owner can always read their own orders
        if (order.getOwnerId().equals(userId)) return true;

        // Admin role can read any order
        Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
        return authorities.stream()
            .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    }
}


For most APIs, this two-layer approach (scope at the method, data check in a bean) is the right shape. Scopes are for “this caller is allowed to do this kind of thing.” Data checks are for “this caller is allowed to do this thing to this specific resource.”

Input Validation Beyond Annotations

Bean validation annotations (@NotNull, @Size, @Email, etc.) catch the simple cases. They don’t catch:

  • Cross-field validation (start date before end date).
  • Conditional validation (if status is “shipped,” tracking number is required).
  • Value normalization (trimming whitespace, normalizing email casing).
  • Defense against the input that’s technically valid but operationally wrong (a 1000-element array of valid email addresses in a request that should accept at most 10).

The pattern that’s worked:

Java
 
public record CreateOrderRequest(
    @NotBlank @Size(max = 100) String customerId,
    @NotEmpty @Size(max = 50) List<@Valid OrderItem> items,
    @Size(max = 500) String notes
) {
    public record OrderItem(
        @NotBlank @Size(max = 50) String productId,
        @Min(1) @Max(999) int quantity,
        @NotNull @DecimalMin("0.00") @Digits(integer = 8, fraction = 2) BigDecimal price
    ) {}
}


Each field has a maximum. The list has a maximum. Money is BigDecimal with explicit digit limits, not double. The annotations cover the structural rules.

For business rules, a separate validator:

Java
 
@Service
public class OrderRequestValidator {

    public void validate(CreateOrderRequest request) {
        BigDecimal total = request.items().stream()
            .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        if (total.compareTo(MAX_ORDER_VALUE) > 0) {
            throw new ValidationException("Order value exceeds maximum");
        }

        // Other business validations...
    }
}


The split keeps the structural validation in the request type (where Spring auto-applies it) and the business validation in a service that the controller calls explicitly.

A validation rule had set a minimum length of five characters on the last name field — a reasonable default that nobody questioned. It broke for users with single-letter last names, and it broke silently for users with no last name at all. The annotation rejected both without a meaningful error. The records existed in the source system; they just couldn't be processed. We found it not through testing but through a support ticket from an operator who couldn't explain why a specific record kept failing. 

The fix was a minimum of one character and explicit handling for the absent last name case. The lesson was that "reasonable default" and "correct default" aren't the same thing, and that test data built from typical cases misses the atypical ones that real populations contain.

Output Encoding

The output side gets less attention than the input side. It deserves equal attention. Specifically:

JSON serialization that doesn’t leak. Don’t return your JPA entity directly. Return a DTO that includes only the fields the API contract specifies. The reasons: an entity has fields that aren’t in the contract (audit columns, internal references, lazy-loaded relationships) and serializing them by accident leaks information. Worse, if you ever add a field to the entity, it gets exposed without a contract change.

Java
 
public record OrderResponse(
    String id,
    String customerId,
    OrderStatus status,
    List<OrderItemResponse> items,
    BigDecimal total,
    Instant createdAt
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
            order.getId(),
            order.getCustomerId(),
            order.getStatus(),
            order.getItems().stream().map(OrderItemResponse::from).toList(),
            order.calculateTotal(),
            order.getCreatedAt()
        );
    }
}


The DTO is explicit about what crosses the wire. Adding a field to the entity doesn’t change the API surface unless somebody also updates the DTO.

Error responses that don’t leak. A controller advice that catches uncaught exceptions and returns a sanitized response:

Java
 
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAny(Exception e) {
        String correlationId = UUID.randomUUID().toString();
        log.error("Unhandled exception, correlationId={}", correlationId, e);

        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(
                "INTERNAL_ERROR",
                "An unexpected error occurred",
                correlationId
            ));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse("VALIDATION_FAILED", "Request validation failed", null));
    }
}


The correlation ID is the link between the response and the server log. The user gets a meaningless ID, the operator can find the full error in the logs, and no internal information leaks through the response.

Rate Limiting

The simple in-memory bucket works for a single instance. The moment you have two instances, the rate limit per instance is double the rate limit per user.

The pattern that scales:

Java
 
@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final RedisRateLimiter limiter;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String key = "rl:" + extractUserKey(request);
        RateLimitDecision decision = limiter.tryConsume(key, 100, Duration.ofMinutes(1));

        response.setHeader("X-RateLimit-Limit", "100");
        response.setHeader("X-RateLimit-Remaining", String.valueOf(decision.remaining()));

        if (!decision.allowed()) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
            return;
        }

        chain.doFilter(request, response);
    }
}


The RedisRateLimiter uses a Redis-backed token bucket (libraries like Bucket4j with Redis support work, or Resilience4j, or you can write the Lua script yourself). The key insight is that the limiter state is shared across all instances of your service.

The 429 response includes a Retry-After header. Clients that respect it back off automatically. Clients that don’t are easier to identify in your logs.

For PHI APIs, rate limiting is also a security signal. A user account suddenly hitting the rate limit is anomalous. Log it, and consider an alert.

The rate limit surfaced something we wouldn't have caught otherwise. A staff account on a records API started hitting the rate limit consistently during off-hours — request volume that made no operational sense for that role at that time. The account wasn't doing anything the API would reject on its own; each individual request was authorized and valid. The pattern was what was wrong. We investigated, found the credentials had been compromised, and revoked them. The rate limit didn't prevent the breach — the credentials had already been in use for some time by the time we investigated. What it did was create a signal we could act on. Without it, the access would have continued until someone noticed the data was wrong, which is a much longer detection window. For APIs handling sensitive records, the 429 log entry is as important as the 403.

CORS Done Specifically

CORS configuration is one of those areas where a small mistake creates a big hole. The pattern:

Java
 
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource(
            @Value("${cors.allowed-origins}") String allowedOrigins) {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setExposedHeaders(List.of("X-RateLimit-Remaining"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}


The allowed origins come from configuration, not hardcoded. The credentials flag is true (we want cookies and Authorization headers to flow), which means the origins must be specific (no wildcards). The exposed headers list is explicit because the browser hides response headers from JavaScript by default.

Logging That Doesn’t Poison Itself

The logging configuration matters as much as anything else. The patterns:

A logging filter that scrubs sensitive fields:

Java
 
public class SensitiveDataFilter extends Filter<ILoggingEvent> {
    private static final Pattern SSN = Pattern.compile("\\b\\d{3}-\\d{2}-\\d{4}\\b");
    private static final Pattern CARD = Pattern.compile("\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b");

    @Override
    public FilterReply decide(ILoggingEvent event) {
        String msg = event.getFormattedMessage();
        if (SSN.matcher(msg).find() || CARD.matcher(msg).find()) {
            // Best-effort: scrub or drop
            // ... in practice, replace with a redacted version
        }
        return FilterReply.NEUTRAL;
    }
}


A regex-based scrubber is imperfect but better than nothing. The preferable approach is to never log the sensitive data in the first place, by being deliberate about what goes into log statements.

Structured logging. JSON logs with consistent field names make queries possible. The correlation ID in the error response comes from the structured log entry. Without it, debugging in production is grep against unstructured text.

Audit logging is separate. The audit log isn’t the application log. It’s a separate stream with separate retention, separate access controls, and a separate schema. Mixing them is the most common mistake I’ve seen.

The one that consumed the most time wasn't a missing control — it was a configuration conflict. A global security config was overriding a local one. The global config had security settings it wasn't supposed to have according to company protocol; security at that level was meant to be handled locally by each service. Because the global config was authoritative in the resolution order, the local configuration's rules were silently ignored. 

Everything appeared to be working — requests were being accepted, responses were coming back — but the security posture in effect wasn't the one anyone had reviewed or intended. It took a long time to find it because nobody thought of looking at the global config; the assumption was that the local config was what was running. The fix was straightforward once we found it. The lesson was that configuration precedence needs to be as explicit and documented as the configuration itself — knowing what your security config says matters less than knowing which security config is active.

What I’d Put On Every Spring Boot API

Reduced to the essentials:

  • JWT authentication with explicit algorithm pinning, audience validation, and issuer validation.
  • Method-level authorization with both scope checks and data-dependent checks.
  • Input validation with Bean Validation annotations on the request types, plus a service-layer validator for business rules.
  • Explicit DTOs for responses, never raw entity serialization.
  • Global exception handling that returns sanitized errors with correlation IDs.
  • Distributed rate limiting with appropriate headers.
  • CORS configured with specific origins and credentials.
  • Structured logging with sensitive field scrubbing.
  • Security headers in the filter chain (CSP, HSTS, frame-options).

The reference implementation isn’t a starting point you keep static. It’s the floor. Every API should have at least this. Beyond it, the security model gets specific to the data, the threat model, and the operational environment. But this floor catches the patterns that scanners flag and that real attackers exploit. Without it, you’re not arguing about defense in depth. You’re missing the basic controls.

JWT (JSON Web Token) Spring Boot

Opinions expressed by DZone contributors are their own.

Related

  • Issue and Present Verifiable Credentials With Spring Boot and Android
  • Scalable JWT Token Revocation in Spring Boot
  • Authentication With Remote LDAP Server in Spring Web MVC
  • How to Implement Two-Factor Authentication in a Spring Boot OAuth Server? Part 1: Configuration

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook