Spring Security Basics: Auth for REST APIs

Two weeks after our checkout API went live, an engineer on the ops team pasted something into Slack that froze the channel: a curl command, run from his laptop, that listed every pending refund in the system. No login, no token, no error. The endpoint /api/admin/refunds had shipped with no authentication at all — we'd built the refund feature, tested it, and never asked who was allowed to call it. This post is the fix: the minimum Spring Security you need to protect a REST API, done the modern way.

This is deliberately basic. A later track goes deep on security — OAuth2 login flows, multi-tenancy, key rotation, threat modeling. Here: the filter chain, passwords, HTTP Basic for internal tools, JWT for real APIs, and method-level authorization. Enough to ship an API that isn't open to the internet.

The 30-second mental model: a chain of filters

Spring Security is not a wall around your controller. It's a chain of servlet filters that every request passes through before it reaches your code. Each filter does one job — read the credentials, verify the token, decide if the caller may proceed — and any filter can stop the request with a 401 or 403. You configure the chain with a single bean, and that bean is the whole configuration surface that matters at this level:

HTTP request GET /api/orders/7 + Authorization SecurityFilterChain 1. Authentication "Who are you?" — reads the Basic header or JWT, verifies it, builds an Authentication 2. Authorization "May you?" — checks the URL rules and @PreAuthorize against your authorities either step can reject allowed Controller 200 + body no/invalid credentials → 401 valid login, wrong permissions → 403 401 = "I don't know you" · 403 = "I know you, and the answer is no"

Two vocabulary notes before the code. Authentication ("who are you?") happens first and produces an Authentication object. Authorization ("may you?") happens second and checks it. And if you find WebSecurityConfigurerAdapter in a tutorial, that tutorial is from the Spring Security 5 era — the class is deprecated and removed; the SecurityFilterChain bean below is its replacement.

SecurityFilterChain: the modern bean

Everything starts here. This bean declares which URLs are public, which need a login, and how credentials are checked — using the lambda DSL:

package com.javamakeuse.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class BasicSecurityConfig {

    // Step 1: the simplest protected API. Internal ops tool? This is enough.
    @Bean
    SecurityFilterChain basicFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())   // no cookies, nothing to forge
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Reading it line by line: csrf.disable() turns off CSRF protection, which is correct for a stateless API — CSRF is an attack on cookie-based browser sessions, and this API has no cookies and no sessions. authorizeHttpRequests lists the URL rules in order: public paths are open, everything else needs authentication. httpBasic enables HTTP Basic authentication. (This is the "before" config — we evolve it to JWT below.)

Passwords: BCrypt, always

Before tokens, the credential everything else builds on: the password. The rule is absolute — never store a password; store a one-way hash of it, made with an algorithm designed for passwords. That means BCrypt (or Argon2/scrypt), not SHA-256: password hashers are deliberately slow and include a random salt per password, so identical passwords hash differently and brute force is expensive. Here's a worked registration and login:

package com.javamakeuse.auth;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserAccountService {

    public record UserAccount(String username, String passwordHash) { }

    private final PasswordEncoder encoder;
    private final Map<String, UserAccount> store = new ConcurrentHashMap<>();

    public UserAccountService(PasswordEncoder encoder) {
        this.encoder = encoder;
    }

    public UserAccount register(String username, String rawPassword) {
        if (store.containsKey(username)) {
            throw new IllegalArgumentException("username taken: " + username);
        }
        // NEVER store the raw password — store the BCrypt hash.
        UserAccount account = new UserAccount(username, encoder.encode(rawPassword));
        store.put(username, account);
        return account;
    }

    public boolean login(String username, String rawPassword) {
        return Optional.ofNullable(store.get(username))
            .map(a -> encoder.matches(rawPassword, a.passwordHash()))
            .orElse(false);
    }
}

The encoder bean lives in the security config:

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();   // adaptive cost, random salt per hash
}

Note what the code never does: decrypt. There is no decrypt — matches() hashes the candidate with the stored salt and compares. And a real BCrypt hash looks like this (genuinely produced by BCryptPasswordEncoder for the password ops-secret; the salt is random, so yours will differ — that's the point):

$2a$10$R3QpYdVl4WAnt92ed2jXZur9.tHYsS1xm.gISzDmQWtyEuG1KFAkq

The $2a$ is the algorithm, 10 is the cost factor (210 rounds), the next 22 characters are the salt, and the rest is the hash. Anyone holding this string still can't recover the password — they'd have to brute-force it, one expensive hash at a time.

HTTP Basic: good enough for internal tools

With the chain from the first section, protecting an endpoint is already done: the client sends Authorization: Basic <base64(user:password)> on every request (illustrative — exact header encoding is standard):

curl -u ops:ops-secret https://api.example.com/api/orders/7
# 200 — the filter chain verified the password against the BCrypt hash

curl https://api.example.com/api/orders/7
# 401 — no credentials, the chain stops the request before the controller

Decision rule: HTTP Basic is for internal tools — an ops dashboard behind a VPN, a partner webhook receiver, a health-check endpoint with a shared credential. It is not for real APIs, for three concrete reasons: the password travels on every request (one leaked log line compromises the account), there is no expiry (a stolen credential works until someone rotates it), and there is no notion of scope (the credential grants everything the user can do). For APIs your customers, partners, or mobile apps call, you want tokens.

JWT: tokens for real APIs

A JWT is a signed, expiring, scoped credential the server issues once and the client presents on each call. The server doesn't look it up anywhere — it verifies the signature, which is what makes it stateless. We issue and verify with Spring Security's OAuth2 resource-server support (spring-security-oauth2-resource-server, version managed to 7.1.1 by spring-boot-starter-parent 4.1.1 — verified on Maven Central; check for newer versions than the one pinned, the coordinates are the stable part).

First the keys. This example uses one HMAC secret (HS256) — fine for a single service issuing and verifying its own tokens; multi-service setups use RSA key pairs instead:

package com.javamakeuse.auth;

import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;

@Configuration
public class JwtConfig {

    // HS256 needs a secret of at least 256 bits (32 bytes).
    // In production this comes from an environment variable or vault —
    // never from source control. (See the Tooling track's secrets post.)
    @Value("${jwt.secret}")
    private String secret;

    private SecretKey hmacKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder() {
        return NimbusJwtEncoder.withSecretKey(hmacKey()).build();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withSecretKey(hmacKey()).build();
    }
}

Then a token service that mints tokens with an issuer, an expiry, a subject, and a scope claim:

package com.javamakeuse.auth;

import java.time.Duration;
import java.time.Instant;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

@Service
public class TokenService {

    private final JwtEncoder encoder;

    public TokenService(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    public String issue(String username) {
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("checkout-api")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plus(Duration.ofHours(2)))
            .subject(username)
            .claim("scope", "orders:read orders:write")
            .build();
        return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}

And here is a real token minted by exactly that code — run locally with the claims fixed so it's byte-for-byte reproducible. (It's expired — iat is 2026-01-01 — so treat it as a specimen, not a credential.) A JWT is three Base64URL parts separated by dots: header, payload, signature:

eyJraWQiOiJPRXNkd3NLYnR3VkJXbUVTbWJ1R0oyUklJNXB1dDJJNkVSM1RnQXl2VUpNIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJpc3MiOiJjaGVja291dC1hcGkiLCJzdWIiOiJvcHMtdXNlciIsImV4cCI6MTc2NzIzMjgwMCwiaWF0IjoxNzY3MjI1NjAwLCJzY29wZSI6Im9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSJ9.E6w2_Ie8ISmX4zctntxU-JvRN0q9JELK5aAh_0Y-jgU

Decoded, the parts say:

header:    {"kid":"OEsdwsKbtwVBWmESmbuGJ2RII5put2I6ER3TgAyvUJM","typ":"JWT","alg":"HS256"}
payload:   {"iss":"checkout-api","sub":"ops-user","exp":1767232800,
            "iat":1767225600,"scope":"orders:read orders:write"}
signature: E6w2_Ie8ISmX4zctntxU-JvRN0q9JELK5aAh_0Y-jgU   ← HMAC-SHA256 over header.payload
header . payload {"alg":"HS256", ...} Base64URL — readable by anyone server secret jwt.secret (env / vault) never in source control signature = HMAC-SHA256(header.payload, secret) tamper with one character → signature mismatch → rejected Verification needs no database lookup: recompute, compare, done.

Two honest warnings about what a JWT is not. First, the payload is encoded, not encrypted — anyone holding the token can read the claims. Never put secrets or PII in there. Second, a JWT can't be individually revoked before expiry without extra machinery (a denylist or short lifetimes plus refresh tokens) — which is why the 2-hour expiry above matters. A token that lives forever is a password with better marketing.

The evolved chain: JWT instead of Basic

With the encoder, decoder, and token service in place, the filter chain grows up: same URL-rule structure as the Basic version, but the resource-server configuration replaces httpBasic, and @EnableMethodSecurity switches on method-level authorization for the next section:

package com.javamakeuse.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // turns on @PreAuthorize / @PostAuthorize
public class SecurityConfig {

    // Step 2: the real API. Stateless JWT, no sessions, no CSRF surface.
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**", "/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();   // adaptive cost, random salt per hash
    }
}

oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) installs the JWT authentication filter, wired to our JwtDecoder bean — every request with a Bearer token gets verified against the HMAC secret before the URL rules run. The /api/auth/** path stays public: that's where the login endpoint lives that trades credentials for tokens.

Method security: @PreAuthorize

URL rules in the filter chain are coarse. For fine-grained control — this method needs the orders:read scope, that one needs the admin role — annotate the method. @EnableMethodSecurity (already on our config) activates it:

package com.javamakeuse.auth;

import org.springframework.security.access.prepost.PreAuthorize;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    @GetMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public String getOrder(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
        return "order " + id + " for " + jwt.getSubject();
    }
}
package com.javamakeuse.auth;

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/admin")
public class AdminController {

    // hasRole('ADMIN') matches an authority named ROLE_ADMIN.
    // The JWT 'scope' claim does NOT grant this — roles and scopes
    // are different authority namespaces. Don't mix them up.
    @GetMapping("/refunds")
    @PreAuthorize("hasRole('ADMIN')")
    public String pendingRefunds() {
        return "[]";
    }
}

Notice the mapping Spring applies: each entry in the JWT scope claim becomes an authority named SCOPE_<scope>. So the token above — with scope: "orders:read orders:write" — passes hasAuthority('SCOPE_orders:read') but fails hasRole('ADMIN'), which looks for ROLE_ADMIN. A token can read orders; it can't touch the admin endpoint. That's the incident from the opening story, fixed at two layers: the URL rule in the chain and the method rule on the controller.

The full request flow, end to end (illustrative curl session):

# 1. trade credentials for a token (your login endpoint)
curl -X POST https://api.example.com/api/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"username":"ops-user","password":"ops-secret"}'
# → {"accessToken":"eyJraWQiOi..."}   (expires in 2 hours)

# 2. call the API with the token
curl https://api.example.com/api/orders/7 \
  -H 'Authorization: Bearer eyJraWQiOi...'
# → 200 "order 7 for ops-user"

# 3. no token → the chain stops you before the controller
curl https://api.example.com/api/orders/7
# → 401

# 4. token without the scope → authenticated, but not authorized
curl https://api.example.com/api/admin/refunds \
  -H 'Authorization: Bearer eyJraWQiOi...'
# → 403

Decision rules

  • HTTP Basic for internal tools, JWT for real APIs. Ops dashboards, partner webhooks, health checks behind a VPN — Basic is simple and sufficient. Anything a customer, partner, or mobile app calls gets short-lived, scoped tokens.
  • Passwords get BCrypt (or Argon2/scrypt) — never SHA-*, never reversible encryption, never plaintext. If you can "decrypt" a stored password, your storage is broken by design.
  • Secrets come from the environment or a vault, never from source control. jwt.secret in application.properties is a placeholder; in production it's an env var injected at deploy time.
  • Roles and scopes are different namespaces. hasRole('ADMIN') checks ROLE_ADMIN; hasAuthority('SCOPE_orders:read') checks the JWT scope claim. Name the right one in @PreAuthorize.

What's next

You can now lock the door: the filter chain, the password hashing, and the tokens. The next post in this track, "Building a Complete Spring Boot API", is the capstone — it assembles this security setup with the transactions, validation, and JPA posts into one tested Orders API, with an integration test that proves the whole thing works.

Field check before you move on: protect one endpoint in a project you own with the filter chain above. Mint a token with TokenService, then curl the endpoint three ways: no header (expect 401), a valid token (expect 200), and the valid token with its last character changed (expect 401 — the signature check rejects the forgery). That third call is the entire value proposition of signed tokens, in one command.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number