Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 36 of 41

Lesson 36 · Secure, Tested, Production-Ready

Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets

Secure the Job Application Tracker API: hash passwords with bcrypt, log in with signed JWT access tokens, enforce that users can only reach their own data, configure CORS and CSRF correctly, keep secrets out of the code, and prove each protection with tests and real requests.

On this page

Key takeaways

  • Authentication proves who you are, and authorisation decides what you may do. Most real breaches come from broken authorisation (one user reaching another's data), not from clever cryptography attacks, so every query must be limited to the caller's own data.
  • Never store passwords, and never store them with a fast hash. Store a salted bcrypt hash (here through Spring's delegating encoder, which writes the algorithm name into the stored value so it can be upgraded). Give unknown email and wrong password the same response.
  • A JWT is a signed, not encrypted, token: anyone can read its payload, and nobody can change it without the secret. Keep it short-lived, never put secrets in it, and keep the signing secret out of the code and out of Git.
  • Spring Security is a chain of filters that runs before your controllers. Deny everything by default, then open only what must be open. CSRF protection is for cookie-based sessions and is correctly disabled for a stateless bearer-token API. CORS is a browser rule, not a security boundary.
  • Fail fast on missing or weak secrets, never print a secret in an error, and test the protections: the tests here include a forged token, an expired token, another user's data and a login that must not reveal which accounts exist.

An API with no security is a public database. This article protects the Job Application Tracker so that only registered users can use it, each user sees only their own applications, passwords are stored safely, and the secrets that make this work are kept out of the code. Security is also one of the most common interview topics for backend roles, so the concepts matter as much as the code.

What you will learn

#
  • The vocabulary and the threats: authentication, authorisation, OWASP risks.
  • How to store passwords, and how JWT tokens work.
  • How Spring Security is organised, and how to configure it for a stateless API.
  • How to enforce per-user access, and how CORS, CSRF and security headers fit in.
  • How to handle secrets, and how to test security.

Every protection here is demonstrated with a test or a real request, on JDK 21 and JDK 25.

The threats, in plain language

#

Think like an attacker for a moment. What could someone do to this API?

  • Read or change someone else's data by guessing an id (GET /api/applications/7). This is broken access control, and it tops the OWASP list.
  • Steal passwords from the database after a leak, and reuse them on other sites.
  • Forge a login token, or reuse a stolen one forever.
  • Inject SQL through input fields.
  • Call the API from a malicious web page while a victim is logged in.
  • Find a secret in the source code or in Git history, and sign their own tokens.
  • Exploit a vulnerable library, or a misconfigured server.

OWASP, a respected open community, publishes a Top 10 list of the most critical web application security risks. The current edition at the time of writing is the OWASP Top 10:2025, whose categories include Broken Access Control, Security Misconfiguration, Software Supply Chain Failures, Cryptographic Failures, Injection, Insecure Design, Authentication Failures, Software or Data Integrity Failures, Security Logging and Alerting Failures, and Mishandling of Exceptional Conditions. (The list was checked on OWASP's site through a summarising fetch tool, so check the official page for exact wording.) This table maps the main ones to what you will do in this course:

Risk

What defends against it here

Broken access control

Every query is limited to the caller's own data; deny by default

Cryptographic failures

Passwords stored as salted bcrypt hashes; tokens signed with a strong secret; HTTPS in production

Injection

PreparedStatement and named parameters (article 28), never string-built SQL

Authentication failures

A strong hash, the same answer for unknown email and wrong password, short-lived tokens

Security misconfiguration

Deny by default, explicit CORS origins, no default or hard-coded secrets, fail fast

Software supply chain failures

Keep dependencies updated and scan them (the build uses a managed set of versions)

Mishandling of exceptional conditions

One error handler, no stack traces or internals in responses (article 33)

Logging failures

Log security events (without secrets), a topic for article 38

Authentication and authorisation

#

Two words that are constantly confused:

Term

Question

Example in this API

Authentication (who are you?)

Prove your identity

Logging in with email and password, then presenting a valid token

Authorisation (what may you do?)

Decide what the identified person may access

"You may read application 7 only if it is yours"

In HTTP terms, a request without valid credentials is answered 401 Unauthorized (the name is historical: it means unauthenticated), and a request from a known user who may not do that is answered 403 Forbidden. Authentication failures usually look obvious in testing. Authorisation failures do not, because everything works for the user who tests it, which is why they are so common.

Sessions and tokens

#

After login, the server has to recognise the user on later requests, because HTTP is stateless (article 30).

Approach

How it works

Trade-offs

Server-side session

The server stores the login state and gives the browser a session id in a cookie, which the browser sends automatically

Easy to revoke. Needs server memory or a shared store. Cookies need CSRF protection

Token (for example JWT)

The server signs a small token that contains the user id and an expiry. The client sends it in an Authorization header

Stateless, so it scales and suits APIs and mobile apps. Hard to revoke before expiry. The client must store it carefully

This API uses signed JWT access tokens, a very common choice for APIs.

Passwords: store a slow, salted hash

#

Never store a password, and never store it encrypted (encryption can be reversed, and the key will leak with the database). Store a hash: a one-way function of the password. A hash is not enough, though. Two requirements:

  1. Slow, on purpose. A fast hash like SHA-256 lets an attacker with a stolen database test billions of guesses per second. A password-hashing function such as bcrypt (or Argon2 or scrypt) has a tunable cost, so each guess takes real time.
  2. Salted. A salt is a random value stored with the hash and mixed into it, so the same password gives a different hash for every user, and precomputed tables ("rainbow tables") are useless. Bcrypt generates and stores the salt for you.

A bcrypt hash looks like $2a$10$ followed by 53 characters: the algorithm version, the cost, the salt and the hash. Spring Security's PasswordEncoderFactories.createDelegatingPasswordEncoder() goes one step further and prefixes the algorithm name: the stored value starts with {bcrypt}. When you later want a stronger algorithm, new passwords use it while old ones still verify, because every stored hash says how it was made. This is the code that configures it, together with the token signing:

Java
// File: src/main/java/com/example/jobtracker/JwtConfig.javapackage com.example.jobtracker;import com.nimbusds.jose.jwk.source.ImmutableSecret;import java.util.Base64;import javax.crypto.SecretKey;import javax.crypto.spec.SecretKeySpec;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.crypto.factory.PasswordEncoderFactories;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.oauth2.jose.jws.MacAlgorithm;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;/** The cryptography: how passwords are hashed and how tokens are signed and checked. */@Configurationpublic class JwtConfig {    private static final int MINIMUM_SECRET_BYTES = 32;    @Bean    SecretKey jwtKey(SecuritySettings settings) {        if (settings.jwtSecret().startsWith("${")) {       // the placeholder was never replaced: the variable is missing            throw new IllegalStateException("security.jwt-secret is not set. Provide the JWT_SECRET environment "                    + "variable: base64 text of at least " + MINIMUM_SECRET_BYTES + " random bytes");        }        byte[] bytes;        try {            bytes = Base64.getDecoder().decode(settings.jwtSecret());        } catch (IllegalArgumentException e) {            throw new IllegalStateException("security.jwt-secret must be base64 text");   // never print the secret itself        }        if (bytes.length < MINIMUM_SECRET_BYTES) {            throw new IllegalStateException("security.jwt-secret must decode to at least "                    + MINIMUM_SECRET_BYTES + " bytes, but it decodes to " + bytes.length);        }        return new SecretKeySpec(bytes, "HmacSHA256");    }    @Bean    JwtEncoder jwtEncoder(SecretKey jwtKey) {        return new NimbusJwtEncoder(new ImmutableSecret<>(jwtKey));    }    @Bean    JwtDecoder jwtDecoder(SecretKey jwtKey) {        return NimbusJwtDecoder.withSecretKey(jwtKey).macAlgorithm(MacAlgorithm.HS256).build();    }    /** Hashes with bcrypt and stores the algorithm name with the hash, so the algorithm can be changed later. */    @Bean    PasswordEncoder passwordEncoder() {        return PasswordEncoderFactories.createDelegatingPasswordEncoder();    }}

Details that matter:

  • Bcrypt only reads the first 72 bytes of a password, so the registration rule is @Size(min = 12, max = 72): long enough to resist guessing, short enough for the algorithm to read it all. Length is a better defence than complicated character rules.
  • Never log a password, and never return the hash in a response.
  • A password reset and email verification are separate features that a production system needs.

JWT: what is in a token

#

A JSON Web Token (JWT, RFC 7519) is three Base64URL-encoded parts joined with dots: a header, a payload of claims, and a signature. This is the decoded content of a real token issued by the application (the two times are replaced by labels because they change on every run, and the token itself is never shown):

Code
header:    {"alg":"HS256"}payload:   {"iss":"jobtracker","sub":"1","exp":"<time, one hour later>","iat":"<time>"}signature: <32 bytes, HMAC-SHA256>
  • The header says how it was signed: HS256, an HMAC with SHA-256, which uses a shared secret.
  • The payload holds claims: iss (who issued it), sub (the subject: here the user id), iat (issued at) and exp (expires at).
  • The signature is computed over the header and payload with the secret. Change one character of the payload and the signature no longer matches.

What a JWT is, and is not:

  • It is signed, not encrypted. Anyone who has the token can read the payload (it is just Base64). Never put passwords or private data in it.
  • It cannot be changed without the secret, so the server can trust the claims it reads, without a database lookup. That is why the controller can take the user id straight from the verified token.
  • It is valid until it expires. There is no easy way to cancel one early (the server holds no record), so keep access tokens short-lived (one hour here, security.token-lifetime). Production systems add a refresh token and a deny-list or short lifetimes for revocation, which this course describes but does not implement.
  • The secret is critical. Anyone with it can create a valid token for any user. It must be long and random, and kept out of the code, the repository and the logs.
  • The classic attack is a token whose header says "alg": "none" or switches the algorithm. Spring's decoder is configured with one expected algorithm (MacAlgorithm.HS256 in JwtConfig) and rejects everything else.
  • Larger systems use an asymmetric algorithm (RS256), where a private key signs and a public key verifies, so that many services can verify tokens without being able to forge them. For one application the shared secret is simpler.

The tokens and the login

#

The user, the settings and the repository come first. The AppUser entity stores only a hash, and the table has a unique email:

SQL
CREATE TABLE users (    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    email         varchar(254) NOT NULL UNIQUE,    password_hash varchar(100) NOT NULL,    created_at    timestamptz  NOT NULL);-- Every application now belongs to one user. NOT NULL works here because the table is empty at this point in the-- course. With existing rows you would add the column as nullable, fill it in, and make it NOT NULL in a later migration.ALTER TABLE job_applications ADD COLUMN owner_id bigint NOT NULL REFERENCES users (id);CREATE INDEX idx_job_applications_owner_id ON job_applications (owner_id);
Java
// File: src/main/java/com/example/jobtracker/AppUser.javapackage com.example.jobtracker;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.time.Instant;/** A person who can log in. The password is stored only as a salted hash, never as text. */@Entity@Table(name = "users")public class AppUser {    @Id    @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;    @Column(nullable = false, unique = true, length = 254)    private String email;    @Column(nullable = false, length = 100)    private String passwordHash;    @Column(nullable = false, updatable = false)    private Instant createdAt;    protected AppUser() {    }    public AppUser(String email, String passwordHash, Instant createdAt) {        this.email = email;        this.passwordHash = passwordHash;        this.createdAt = createdAt;    }    public Long getId() {        return id;    }    public String getEmail() {        return email;    }    public String getPasswordHash() {        return passwordHash;    }}
Java
// File: src/main/java/com/example/jobtracker/AppUserRepository.javapackage com.example.jobtracker;import java.util.Optional;import org.springframework.data.jpa.repository.JpaRepository;public interface AppUserRepository extends JpaRepository<AppUser, Long> {    Optional<AppUser> findByEmail(String email);    boolean existsByEmail(String email);}

The settings are bound and validated like any configuration (article 32). Note that the secret has no default, and see the next section for why application.properties cannot hold it:

Java
// File: src/main/java/com/example/jobtracker/SecuritySettings.javapackage com.example.jobtracker;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.NotEmpty;import java.time.Duration;import java.util.List;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.boot.context.properties.bind.DefaultValue;import org.springframework.validation.annotation.Validated;/** Security settings. The signing secret must be supplied from outside the code. */@ConfigurationProperties(prefix = "security")@Validatedpublic record SecuritySettings(        @NotBlank String jwtSecret,        @DefaultValue("PT1H") Duration tokenLifetime,        @NotEmpty @DefaultValue("http://localhost:3000") List<String> allowedOrigins) {}
Code
tracker.default-page-size=20tracker.max-page-size=50# The database. The address and user can be overridden, and the password must come from the environment.spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/jobtracker}spring.datasource.username=${DB_USER:jobtracker}spring.datasource.password=${DB_PASSWORD}# Flyway creates and changes the tables. Hibernate only checks that the entities match them.spring.jpa.hibernate.ddl-auto=validatespring.jpa.open-in-view=false# Security. The secret has no default: it is base64 text of at least 32 random bytes, and comes from the environment.security.jwt-secret=${JWT_SECRET}security.token-lifetime=PT1Hsecurity.allowed-origins=http://localhost:3000

TokenService creates a signed token whose subject is the user's id:

Java
// File: src/main/java/com/example/jobtracker/TokenService.javapackage com.example.jobtracker;import java.time.Clock;import java.time.Instant;import org.springframework.security.oauth2.jose.jws.MacAlgorithm;import org.springframework.security.oauth2.jwt.JwsHeader;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;/** Issues signed access tokens. The token says who the user is (the subject) and when it stops being valid. */@Servicepublic class TokenService {    private static final String ISSUER = "jobtracker";    private final JwtEncoder encoder;    private final SecuritySettings settings;    private final Clock clock;    public TokenService(JwtEncoder encoder, SecuritySettings settings, Clock clock) {        this.encoder = encoder;        this.settings = settings;        this.clock = clock;    }    public TokenResponse issueFor(AppUser user) {        Instant now = Instant.now(clock);        JwtClaimsSet claims = JwtClaimsSet.builder()                .issuer(ISSUER)                .subject(String.valueOf(user.getId()))                .issuedAt(now)                .expiresAt(now.plus(settings.tokenLifetime()))                .build();        String token = encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims))                .getTokenValue();        return new TokenResponse(token, "Bearer", settings.tokenLifetime().toSeconds());    }}

AuthService registers and logs in. Look for four security decisions: the email is normalised (trimmed and lower-cased), so ADA@example.com and ada@example.com are one account; the password is hashed before it is saved; and login gives the same failure for a wrong password and for an unknown email, and does the same amount of work in both cases, by checking the password against a dummy hash when the email is unknown. Otherwise an attacker could discover which emails are registered, by the response or by how long it takes:

Java
// File: src/main/java/com/example/jobtracker/AuthService.javapackage com.example.jobtracker;import java.time.Clock;import java.time.Instant;import java.util.Locale;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Service@Transactionalpublic class AuthService {    private final AppUserRepository users;    private final PasswordEncoder passwordEncoder;    private final TokenService tokens;    private final Clock clock;    private final String dummyHash;    public AuthService(AppUserRepository users, PasswordEncoder passwordEncoder, TokenService tokens, Clock clock) {        this.users = users;        this.passwordEncoder = passwordEncoder;        this.tokens = tokens;        this.clock = clock;        this.dummyHash = passwordEncoder.encode("a password nobody has");   // lets an unknown email cost the same time    }    public UserResponse register(RegisterRequest request) {        String email = normalise(request.email());        if (users.existsByEmail(email)) {            throw new EmailAlreadyUsedException();        }        AppUser user = new AppUser(email, passwordEncoder.encode(request.password()), Instant.now(clock));        AppUser saved = users.saveAndFlush(user);        return new UserResponse(saved.getId(), saved.getEmail());    }    @Transactional(readOnly = true)    public TokenResponse login(LoginRequest request) {        AppUser user = users.findByEmail(normalise(request.email())).orElse(null);        String hash = user == null ? dummyHash : user.getPasswordHash();        boolean matches = passwordEncoder.matches(request.password(), hash);    // always runs, so timing does not reveal accounts        if (user == null || !matches) {            throw new InvalidCredentialsException();        }        return tokens.issueFor(user);    }    private static String normalise(String email) {        return email.strip().toLowerCase(Locale.ROOT);    }}

The controller exposes the two endpoints. They are the only ones that can be called without a token. The request DTOs carry validation (a well-formed email, a password length from 12 to 72):

Java
// File: src/main/java/com/example/jobtracker/AuthController.javapackage com.example.jobtracker;import jakarta.validation.Valid;import org.springframework.http.HttpStatus;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestController;/** The only endpoints that can be called without a token. */@RestController@RequestMapping("/api/auth")public class AuthController {    private final AuthService service;    public AuthController(AuthService service) {        this.service = service;    }    @PostMapping("/register")    @ResponseStatus(HttpStatus.CREATED)    public UserResponse register(@Valid @RequestBody RegisterRequest request) {        return service.register(request);    }    @PostMapping("/login")    public TokenResponse login(@Valid @RequestBody LoginRequest request) {        return service.login(request);    }}
Java
// File: src/main/java/com/example/jobtracker/RegisterRequest.javapackage com.example.jobtracker;import jakarta.validation.constraints.Email;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;public record RegisterRequest(        @NotBlank @Email @Size(max = 254) String email,        @NotBlank @Size(min = 12, max = 72) String password) {}
Java
// File: src/main/java/com/example/jobtracker/LoginRequest.javapackage com.example.jobtracker;import jakarta.validation.constraints.NotBlank;public record LoginRequest(@NotBlank String email, @NotBlank String password) {}
Java
// File: src/main/java/com/example/jobtracker/TokenResponse.javapackage com.example.jobtracker;public record TokenResponse(String accessToken, String tokenType, long expiresInSeconds) {}
Java
// File: src/main/java/com/example/jobtracker/UserResponse.javapackage com.example.jobtracker;public record UserResponse(Long id, String email) {}
Java
// File: src/main/java/com/example/jobtracker/EmailAlreadyUsedException.javapackage com.example.jobtracker;public class EmailAlreadyUsedException extends RuntimeException {    public EmailAlreadyUsedException() {        super("That email address is already registered");    }}
Java
// File: src/main/java/com/example/jobtracker/InvalidCredentialsException.javapackage com.example.jobtracker;/** The same message for an unknown email and a wrong password, so the response does not reveal which accounts exist. */public class InvalidCredentialsException extends RuntimeException {    public InvalidCredentialsException() {        super("Invalid email or password");    }}

Spring Security: a chain of filters

#

Spring Security is not annotations scattered through your code. It is a chain of servlet filters that every request passes through before it reaches a controller. The filters extract credentials, authenticate, check authorisation, handle errors, add headers and apply CORS and CSRF rules. You configure the chain by defining a SecurityFilterChain bean:

Java
// File: src/main/java/com/example/jobtracker/SecurityConfig.javapackage com.example.jobtracker;import java.time.Duration;import java.util.List;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.http.ProblemDetail;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.config.http.SessionCreationPolicy;import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;import org.springframework.security.web.AuthenticationEntryPoint;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.access.AccessDeniedHandler;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.CorsConfigurationSource;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import tools.jackson.databind.json.JsonMapper;/** Who may call what: everything needs a valid token except registering and logging in. */@Configuration@EnableWebSecuritypublic class SecurityConfig {    @Bean    SecurityFilterChain apiSecurity(HttpSecurity http, JsonMapper json) throws Exception {        AuthenticationEntryPoint unauthorized = (request, response, exception) -> {            new BearerTokenAuthenticationEntryPoint().commence(request, response, exception);   // 401 and WWW-Authenticate            writeProblem(response, json, HttpStatus.UNAUTHORIZED, "Unauthorized",                    "A valid access token is required. Log in and send it as: Authorization: Bearer <token>");        };        AccessDeniedHandler forbidden = (request, response, exception) -> {            response.setStatus(HttpStatus.FORBIDDEN.value());            writeProblem(response, json, HttpStatus.FORBIDDEN, "Forbidden", "You are not allowed to do that");        };        http                // CSRF protects browsers that send a session cookie automatically. This API uses no cookies, and                // a token in the Authorization header is never sent by a browser on its own, so CSRF does not apply.                .csrf(csrf -> csrf.disable())                .cors(Customizer.withDefaults())                                  // uses the CorsConfigurationSource below                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))                .authorizeHttpRequests(requests -> requests                        .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()                        .anyRequest().authenticated())                            // deny by default                .oauth2ResourceServer(oauth -> oauth                        .jwt(Customizer.withDefaults())                        .authenticationEntryPoint(unauthorized)                        .accessDeniedHandler(forbidden))                .exceptionHandling(errors -> errors                        .authenticationEntryPoint(unauthorized)                        .accessDeniedHandler(forbidden));        return http.build();    }    /** Which web pages (origins) may call this API from a browser. */    @Bean    CorsConfigurationSource corsConfigurationSource(SecuritySettings settings) {        CorsConfiguration cors = new CorsConfiguration();        cors.setAllowedOrigins(settings.allowedOrigins());        cors.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));        cors.setAllowedHeaders(List.of("Authorization", "Content-Type"));        cors.setMaxAge(Duration.ofHours(1));        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();        source.registerCorsConfiguration("/api/**", cors);        return source;    }    private static void writeProblem(jakarta.servlet.http.HttpServletResponse response, JsonMapper json,                                     HttpStatus status, String title, String detail) throws java.io.IOException {        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);        problem.setTitle(title);        response.setStatus(status.value());        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);        response.setCharacterEncoding("UTF-8");        response.getWriter().write(json.writeValueAsString(problem));    }}

Reading it section by section:

  • authorizeHttpRequests lists the rules in order. POST /api/auth/register and POST /api/auth/login are permitAll. anyRequest().authenticated() closes everything else: this is deny by default, so a new endpoint (or a forgotten one such as an admin page) is protected unless someone deliberately opens it. The opposite design, allowing everything except a list, is how endpoints get exposed by accident.
  • oauth2ResourceServer(... jwt ...) adds a filter that reads Authorization: Bearer <token>, verifies the signature and expiry with the JwtDecoder, and makes the verified token available to the controllers. "Resource server" is OAuth 2 terminology for "an API that accepts tokens". No part of this API depends on a third-party login provider, but Spring's resource-server support is the standard way to handle bearer tokens.
  • STATELESS sessions: no session is created or used, so every request must carry its token.
  • CSRF is disabled, deliberately. Cross-site request forgery tricks a logged-in user's browser into sending a request to your site, relying on the browser attaching the session cookie automatically. A bearer token in an Authorization header is never attached automatically by a browser, and this API uses no cookies, so the attack does not apply. If you used cookie-based sessions, you would keep CSRF protection on. The rule is not "always off", it is "off because there are no ambient credentials".
  • The entry point and the denied handler make 401 and 403 responses use the same problem-details JSON as every other error, and the entry point also sets the WWW-Authenticate header, which tells clients how to authenticate.
  • CORS is covered below.

Who is the caller? The token, never the request body

#

Look at how the controller learns who is calling. It takes the verified token as a parameter with @AuthenticationPrincipal Jwt and reads the subject. A client cannot lie about it. A design where the client sends "userId": 5 in the request body would let anyone act as anyone.

Authorisation: every query is limited to the owner

#

Having a valid token proves who you are. It does not stop user 1 from asking for application 7 if application 7 belongs to user 2. This is the insecure direct object reference (IDOR) problem, one form of broken access control, and the fix is to make ownership part of every lookup:

Diff
--- a/src/main/java/com/example/jobtracker/JobApplicationRepository.java+++ b/src/main/java/com/example/jobtracker/JobApplicationRepository.java@@ -1,4 +1,5 @@ package com.example.jobtracker; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable;@@ -8,19 +9,23 @@  /**- * There is no implementation class. Spring Data creates one at start-up from this interface.- * save, findById, findAll, deleteById and many more come from JpaRepository.+ * Every query is limited to one owner, so a user can never reach another user's applications through this class.  */ public interface JobApplicationRepository extends JpaRepository<JobApplication, Long> { +    /** Looks an application up by id, but only if it belongs to the given user. */+    Optional<JobApplication> findByIdAndOwnerId(Long id, Long ownerId);+     /**-     * Both filters are optional: a null status or a null company means "do not filter on it".+     * The owner's applications. Both filters are optional: a null status or a null company means "do not filter on it".      * The cast tells PostgreSQL that the company parameter is text even when it is null.      */     @Query("""             select a from JobApplication a-            where (:status is null or a.status = :status)+            where a.ownerId = :ownerId+              and (:status is null or a.status = :status)               and (:company is null or lower(a.company) like lower(concat('%', cast(:company as string), '%')))             """)-    Page<JobApplication> search(@Param("status") ApplicationStatus status,+    Page<JobApplication> search(@Param("ownerId") Long ownerId,+                                @Param("status") ApplicationStatus status,                                 @Param("company") String company,                                 Pageable pageable);
Diff
--- a/src/main/java/com/example/jobtracker/JobApplicationService.java+++ b/src/main/java/com/example/jobtracker/JobApplicationService.java@@ -10,5 +10,8 @@ import org.springframework.transaction.annotation.Transactional; -/** The rules of the application. It knows nothing about HTTP, and now storage is a database. */+/**+ * The rules of the application. Every method takes the id of the user making the request and only ever touches that+ * user's applications. An application that belongs to someone else behaves exactly like one that does not exist.+ */ @Service @Transactional@@ -25,6 +28,6 @@      /** A new application starts as APPLIED if the user says when they applied, otherwise as WISHLIST. */-    public JobApplicationResponse create(JobApplicationRequest request) {-        JobApplication application = new JobApplication(request.company(), request.position());+    public JobApplicationResponse create(Long ownerId, JobApplicationRequest request) {+        JobApplication application = new JobApplication(ownerId, request.company(), request.position());         copyFields(request, application);         application.setStatus(request.appliedOn() == null ? ApplicationStatus.WISHLIST : ApplicationStatus.APPLIED);@@ -36,16 +39,17 @@      @Transactional(readOnly = true)-    public JobApplicationResponse get(Long id) {-        return JobApplicationResponse.from(find(id));+    public JobApplicationResponse get(Long ownerId, Long id) {+        return JobApplicationResponse.from(find(ownerId, id));     }      /** Filters by status and by part of the company name in the database, and returns one page, oldest first. */     @Transactional(readOnly = true)-    public PageResponse<JobApplicationResponse> list(ApplicationStatus status, String company, Integer page, Integer size) {+    public PageResponse<JobApplicationResponse> list(Long ownerId, ApplicationStatus status, String company,+                                                     Integer page, Integer size) {         int pageSize = size == null ? settings.defaultPageSize() : Math.min(size, settings.maxPageSize());         int pageNumber = page == null ? 0 : page;         String companyFilter = company == null || company.isBlank() ? null : company.strip(); -        Page<JobApplication> result = repository.search(status, companyFilter,+        Page<JobApplication> result = repository.search(ownerId, status, companyFilter,                 PageRequest.of(pageNumber, pageSize, Sort.by("id"))); @@ -55,6 +59,6 @@      /** Replaces the editable fields. The status is not editable here, see changeStatus. */-    public JobApplicationResponse replace(Long id, JobApplicationRequest request) {-        JobApplication application = find(id);+    public JobApplicationResponse replace(Long ownerId, Long id, JobApplicationRequest request) {+        JobApplication application = find(ownerId, id);         if (request.version() != null && request.version() != application.getVersion()) {             throw new StaleVersionException(id, request.version(), application.getVersion());@@ -65,6 +69,6 @@     } -    public JobApplicationResponse changeStatus(Long id, ApplicationStatus next) {-        JobApplication application = find(id);+    public JobApplicationResponse changeStatus(Long ownerId, Long id, ApplicationStatus next) {+        JobApplication application = find(ownerId, id);         if (!application.getStatus().canMoveTo(next)) {             throw new InvalidStatusChangeException(application.getStatus(), next);@@ -75,6 +79,6 @@     } -    public InterviewResponse addInterview(Long applicationId, InterviewRequest request) {-        JobApplication application = find(applicationId);+    public InterviewResponse addInterview(Long ownerId, Long applicationId, InterviewRequest request) {+        JobApplication application = find(ownerId, applicationId);         Interview interview = new Interview(request.scheduledAt(), request.type(), request.notes());         application.addInterview(interview);@@ -84,10 +88,10 @@      @Transactional(readOnly = true)-    public List<InterviewResponse> listInterviews(Long applicationId) {-        return find(applicationId).getInterviews().stream().map(InterviewResponse::from).toList();+    public List<InterviewResponse> listInterviews(Long ownerId, Long applicationId) {+        return find(ownerId, applicationId).getInterviews().stream().map(InterviewResponse::from).toList();     } -    public void deleteInterview(Long applicationId, Long interviewId) {-        JobApplication application = find(applicationId);+    public void deleteInterview(Long ownerId, Long applicationId, Long interviewId) {+        JobApplication application = find(ownerId, applicationId);         Interview interview = application.getInterviews().stream()                 .filter(i -> i.getId().equals(interviewId))@@ -97,10 +101,10 @@     } -    public void delete(Long id) {-        repository.delete(find(id));                // throws if it does not exist+    public void delete(Long ownerId, Long id) {+        repository.delete(find(ownerId, id));        // throws if it does not exist or belongs to someone else     } -    private JobApplication find(Long id) {-        return repository.findById(id).orElseThrow(() -> new ApplicationNotFoundException(id));+    private JobApplication find(Long ownerId, Long id) {+        return repository.findByIdAndOwnerId(id, ownerId).orElseThrow(() -> new ApplicationNotFoundException(id));     } 
Diff
--- a/src/main/java/com/example/jobtracker/JobApplicationController.java+++ b/src/main/java/com/example/jobtracker/JobApplicationController.java@@ -7,4 +7,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity;+import org.springframework.security.core.annotation.AuthenticationPrincipal;+import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping;@@ -19,5 +21,8 @@ import org.springframework.web.util.UriComponentsBuilder; -/** The HTTP layer: translate requests into service calls and results into responses. No rules live here. */+/**+ * The HTTP layer: translate requests into service calls and results into responses. No rules live here.+ * The user comes from the verified token, never from anything the client could simply claim in the request body.+ */ @RestController @RequestMapping("/api/applications")@@ -29,8 +34,14 @@     } +    /** The token's subject is the user's id, put there by TokenService when the user logged in. */+    private static Long userId(Jwt token) {+        return Long.valueOf(token.getSubject());+    }+     @PostMapping-    public ResponseEntity<JobApplicationResponse> create(@Valid @RequestBody JobApplicationRequest request,+    public ResponseEntity<JobApplicationResponse> create(@AuthenticationPrincipal Jwt token,+                                                         @Valid @RequestBody JobApplicationRequest request,                                                          UriComponentsBuilder uriBuilder) {-        JobApplicationResponse created = service.create(request);+        JobApplicationResponse created = service.create(userId(token), request);         URI location = uriBuilder.path("/api/applications/{id}").buildAndExpand(created.id()).toUri();         return ResponseEntity.created(location).body(created);@@ -38,38 +49,41 @@      @GetMapping("/{id}")-    public JobApplicationResponse get(@PathVariable Long id) {-        return service.get(id);+    public JobApplicationResponse get(@AuthenticationPrincipal Jwt token, @PathVariable Long id) {+        return service.get(userId(token), id);     }      @GetMapping     public PageResponse<JobApplicationResponse> list(+            @AuthenticationPrincipal Jwt token,             @RequestParam(required = false) ApplicationStatus status,             @RequestParam(required = false) String company,             @RequestParam(required = false) @Min(0) Integer page,             @RequestParam(required = false) @Min(1) Integer size) {-        return service.list(status, company, page, size);+        return service.list(userId(token), status, company, page, size);     }      @PutMapping("/{id}")-    public JobApplicationResponse replace(@PathVariable Long id, @Valid @RequestBody JobApplicationRequest request) {-        return service.replace(id, request);+    public JobApplicationResponse replace(@AuthenticationPrincipal Jwt token, @PathVariable Long id,+                                          @Valid @RequestBody JobApplicationRequest request) {+        return service.replace(userId(token), id, request);     }      @PostMapping("/{id}/status")-    public JobApplicationResponse changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) {-        return service.changeStatus(id, request.status());+    public JobApplicationResponse changeStatus(@AuthenticationPrincipal Jwt token, @PathVariable Long id,+                                               @Valid @RequestBody StatusChangeRequest request) {+        return service.changeStatus(userId(token), id, request.status());     }      @DeleteMapping("/{id}")     @ResponseStatus(HttpStatus.NO_CONTENT)-    public void delete(@PathVariable Long id) {-        service.delete(id);+    public void delete(@AuthenticationPrincipal Jwt token, @PathVariable Long id) {+        service.delete(userId(token), id);     }      @PostMapping("/{id}/interviews")-    public ResponseEntity<InterviewResponse> addInterview(@PathVariable Long id,+    public ResponseEntity<InterviewResponse> addInterview(@AuthenticationPrincipal Jwt token, @PathVariable Long id,                                                           @Valid @RequestBody InterviewRequest request,                                                           UriComponentsBuilder uriBuilder) {-        InterviewResponse created = service.addInterview(id, request);+        InterviewResponse created = service.addInterview(userId(token), id, request);         URI location = uriBuilder.path("/api/applications/{id}/interviews/{interviewId}")                 .buildAndExpand(id, created.id()).toUri();@@ -78,12 +92,13 @@      @GetMapping("/{id}/interviews")-    public List<InterviewResponse> listInterviews(@PathVariable Long id) {-        return service.listInterviews(id);+    public List<InterviewResponse> listInterviews(@AuthenticationPrincipal Jwt token, @PathVariable Long id) {+        return service.listInterviews(userId(token), id);     }      @DeleteMapping("/{id}/interviews/{interviewId}")     @ResponseStatus(HttpStatus.NO_CONTENT)-    public void deleteInterview(@PathVariable Long id, @PathVariable Long interviewId) {-        service.deleteInterview(id, interviewId);+    public void deleteInterview(@AuthenticationPrincipal Jwt token, @PathVariable Long id,+                                @PathVariable Long interviewId) {+        service.deleteInterview(userId(token), id, interviewId);     } }
Diff
--- a/src/main/java/com/example/jobtracker/JobApplication.java+++ b/src/main/java/com/example/jobtracker/JobApplication.java@@ -31,4 +31,8 @@     @Version     private long version;++    /** The user this application belongs to. It is set once, when the application is created. */+    @Column(nullable = false, updatable = false)+    private Long ownerId;      @Column(nullable = false, length = 100)@@ -69,5 +73,6 @@     } -    public JobApplication(String company, String position) {+    public JobApplication(Long ownerId, String company, String position) {+        this.ownerId = ownerId;         this.company = company;         this.position = position;@@ -76,4 +81,8 @@     public Long getId() {         return id;+    }++    public Long getOwnerId() {+        return ownerId;     } 

Study the pattern, because it is the heart of the article:

  1. The application stores its ownerId, set once at creation and never changed (updatable = false).
  2. The repository has no way to fetch "any application by id": it has findByIdAndOwnerId and a search that always includes a.ownerId = :ownerId.
  3. The service takes the owner id in every method and passes it down. A forgotten check is impossible by construction, because there is no un-owned method to call.
  4. An application that belongs to someone else behaves exactly like one that does not exist: 404 Not Found, not 403. That is deliberate. A 403 would confirm that application 7 exists, which leaks information. (Some APIs prefer 403 for clarity. The important thing is that the data is never returned.)

For role-based rules ("only an admin may delete users") Spring Security offers hasRole in the configuration and @PreAuthorize("hasRole('ADMIN')") on methods. They are not needed here, because ownership covers this API's rules.

The exception handler adds the two new responses (a duplicate email is a 409, bad credentials a 401):

Diff
--- a/src/main/java/com/example/jobtracker/ApiExceptionHandler.java+++ b/src/main/java/com/example/jobtracker/ApiExceptionHandler.java@@ -56,4 +56,18 @@     } +    @ExceptionHandler(EmailAlreadyUsedException.class)+    ProblemDetail emailAlreadyUsed(EmailAlreadyUsedException e) {+        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());+        problem.setTitle("Email already registered");+        return problem;+    }++    @ExceptionHandler(InvalidCredentialsException.class)+    ProblemDetail invalidCredentials(InvalidCredentialsException e) {+        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage());+        problem.setTitle("Login failed");+        return problem;+    }+     @ExceptionHandler(InvalidStatusChangeException.class)     ProblemDetail invalidStatusChange(InvalidStatusChangeException e) {

CORS

#

Browsers enforce the same-origin policy: a script on https://app.example.com is not allowed to read responses from https://api.example.com, because they are different origins (scheme, host and port). CORS (cross-origin resource sharing) is the controlled relaxation: the API tells the browser which other origins it trusts. For requests that could have side effects, the browser first sends a preflight OPTIONS request asking "may I?", and only then the real one.

The configuration allows exactly the origins named in security.allowed-origins, the methods the API uses and the headers it needs. These are real preflight requests: one from the allowed origin, and one from an origin that is not on the list.

Code
$ curl -i -X OPTIONS http://localhost:18084/api/applications -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type'HTTP/1.1 200 Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: GET,POST,PUT,DELETEAccess-Control-Allow-Headers: authorization, content-typeAccess-Control-Max-Age: 3600$ curl -i -X OPTIONS http://localhost:18084/api/applications -H 'Origin: https://evil.example.net' -H 'Access-Control-Request-Method: POST'HTTP/1.1 403 Invalid CORS request$ curl -i http://localhost:18084/api/applications -H 'Authorization: Bearer <token>'    (response headers only)HTTP/1.1 200 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersX-Content-Type-Options: nosniffX-XSS-Protection: 0Cache-Control: no-cache, no-store, max-age=0, must-revalidatePragma: no-cacheExpires: 0X-Frame-Options: DENYContent-Type: application/json

(In this run the allowed origin came from the environment variable SECURITY_ALLOWEDORIGINS, which Spring Boot maps to security.allowed-origins.) Three things to notice in that output:

  • The allowed page received Access-Control-Allow-Origin with its own origin, the methods and headers it may use, and a cache time for the preflight result.
  • The unknown origin got 403 Invalid CORS request and no Access-Control- headers.
  • The last request shows the security headers that Spring Security adds to every response by default: X-Content-Type-Options: nosniff (do not guess content types), X-Frame-Options: DENY (do not allow the page to be framed, which prevents clickjacking), and Cache-Control: no-cache, no-store (do not cache authenticated responses).

CORS is not an access control. It only instructs browsers. A script, curl or another server ignores it completely. That is why the real protection is the token and the ownership checks. A common and dangerous mistake is allowing every origin (*) together with credentials "to make the error go away".

Secrets and configuration

#

The signing secret is the most sensitive value in the application. The rules, all visible in the code:

  • No default value, and not in application.properties. The property is security.jwt-secret=${JWT_SECRET}: the file only says where to read it from. The value comes from the environment (article 32). A secret committed to Git is compromised forever, even if you delete it later (article 24).
  • Strong: at least 32 random bytes, Base64-encoded. One way to generate it, as used for the runs in this article: head -c 32 /dev/urandom | base64.
  • Fail fast, with a message that helps and never reveals. If the variable is missing, or too short, the application refuses to start. These are the real start-up failures (the secret is never printed):
Code
o.s.boot.SpringApplication               : Application run failedCaused by: java.lang.IllegalStateException: security.jwt-secret is not set. Provide the JWT_SECRET environment variable: base64 text of at least 32 random bytes
Code
o.s.boot.SpringApplication               : Application run failedCaused by: java.lang.IllegalStateException: security.jwt-secret must decode to at least 32 bytes, but it decodes to 9

Note the first one. When a placeholder like ${JWT_SECRET} cannot be resolved, Spring Boot's configuration binding keeps the literal text ${JWT_SECRET} instead of failing. The first version of this class decoded that text as Base64 and failed with a confusing "Illegal base64 character 24" message (character 24 hexadecimal is $). The explicit check for a leading ${ turns it into an instruction. Equally important is what the messages do not contain: the value. Validating the secret with a Bean Validation annotation would have made Boot print the offending value in its failure report, which is exactly what you must not do with a secret.

  • Rotate it if it may have leaked: changing the secret invalidates every issued token, and users log in again.
  • In production, secrets come from the platform: environment variables injected by the orchestrator, or a secret manager (article 38).

The API in action

#

This is the real session. A request without a token is refused. Then Ada registers (with a duplicate, an invalid request, and logins that fail), and logs in:

Code
$ curl -i http://localhost:18084/api/applicationsHTTP/1.1 401 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersWWW-Authenticate: Bearer resource_metadata="http://localhost:18084/.well-known/oauth-protected-resource"Content-Type: application/problem+json;charset=UTF-8{"detail":"A valid access token is required. Log in and send it as: Authorization: Bearer <token>","status":401,"title":"Unauthorized"}$ curl -i -X POST http://localhost:18084/api/auth/register -H 'Content-Type: application/json' -d '{"email":"ada@example.com","password":"correct horse battery"}'HTTP/1.1 201 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/json{"id":1,"email":"ada@example.com"}$ curl -i -X POST http://localhost:18084/api/auth/register -H 'Content-Type: application/json' -d '{"email":"ADA@example.com","password":"correct horse battery"}'HTTP/1.1 409 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/problem+json{"detail":"That email address is already registered","instance":"/api/auth/register","status":409,"title":"Email already registered"}$ curl -i -X POST http://localhost:18084/api/auth/register -H 'Content-Type: application/json' -d '{"email":"not-an-email","password":"short"}'HTTP/1.1 400 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/problem+json{"detail":"The request has invalid fields","instance":"/api/auth/register","status":400,"title":"Validation failed","errors":{"email":"must be a well-formed email address","password":"size must be between 12 and 72"}}$ curl -i -X POST http://localhost:18084/api/auth/login -H 'Content-Type: application/json' -d '{"email":"ada@example.com","password":"wrong password here"}'HTTP/1.1 401 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/problem+json{"detail":"Invalid email or password","instance":"/api/auth/login","status":401,"title":"Login failed"}$ curl -i -X POST http://localhost:18084/api/auth/login -H 'Content-Type: application/json' -d '{"email":"nobody@example.com","password":"wrong password here"}'HTTP/1.1 401 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/problem+json{"detail":"Invalid email or password","instance":"/api/auth/login","status":401,"title":"Login failed"}$ curl -i -X POST http://localhost:18084/api/auth/login -H 'Content-Type: application/json' -d '{"email":"ada@example.com","password":"correct horse battery"}'HTTP/1.1 200 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/json{"accessToken":"<token>","tokenType":"Bearer","expiresInSeconds":3600}

Read what each response tells you:

  • The first request returned 401, with a WWW-Authenticate header and a problem-details body telling the client how to authenticate.
  • The duplicate registration, with a different capitalisation of the same email, was caught by the normalisation and returned 409.
  • The invalid registration returned 400 with a field list: the malformed email and the password length (size must be between 12 and 72).
  • The wrong password and the unknown email produced identical 401 bodies, as designed. The accessToken is replaced by <token> in this log.

Ada then creates an application with her token. The Authorization header is Bearer followed by the token:

Code
$ curl -i -X POST http://localhost:18084/api/applications -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"company":"Acme","position":"Java Developer"}'HTTP/1.1 201 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersLocation: http://localhost:18084/api/applications/1Content-Type: application/json{"id":1,"version":0,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}

Now the checks that matter most. Ada reads her application. Grace, a second user, tries to read it by id and then lists her own applications. Finally a request carries Ada's token with its last character changed:

Code
$ curl -i http://localhost:18084/api/applications/1 -H 'Authorization: Bearer <Ada's token>'HTTP/1.1 200 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/json{"id":1,"version":0,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}$ curl -i http://localhost:18084/api/applications/1 -H 'Authorization: Bearer <Grace's token>'HTTP/1.1 404 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/problem+json{"detail":"No job application with id 1","instance":"/api/applications/1","status":404,"title":"Application not found"}$ curl -i http://localhost:18084/api/applications -H 'Authorization: Bearer <Grace's token>'HTTP/1.1 200 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersContent-Type: application/json{"items":[],"page":0,"size":20,"totalItems":0,"totalPages":0}$ curl -i http://localhost:18084/api/applications -H 'Authorization: Bearer <Ada's token with the last character changed>'HTTP/1.1 401 Vary: OriginVary: Access-Control-Request-MethodVary: Access-Control-Request-HeadersWWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:18084/.well-known/oauth-protected-resource"Content-Type: application/problem+json;charset=UTF-8{"detail":"A valid access token is required. Log in and send it as: Authorization: Bearer <token>","status":401,"title":"Unauthorized"}
  • Ada gets her application. Grace gets 404 for the same id, and her list is empty, so Ada's data is invisible to her.
  • The tampered token returned 401, and the WWW-Authenticate header reports the reason the decoder gave: Signed JWT rejected: Invalid signature. Changing even one character broke the signature check.

The tests

#

Security needs tests more than most code, because nobody notices a missing check until an attacker finds it. The suite now has 75 tests, all passing on JDK 21 and JDK 25. The security ones:

  • SecurityRulesTest is a web-layer slice test with the real security configuration and mocked services. It proves that a request without a token is a 401 with a problem body and never reaches the service, that a forged token is a 401, that a valid token passes, that register and login need no token, that a short password is rejected before the service, that everything else is denied by default, and the CORS rules (an allowed origin passes the preflight, another is refused):
Java
// File: src/test/java/com/example/jobtracker/SecurityRulesTest.javapackage com.example.jobtracker;import static org.mockito.ArgumentMatchers.any;import static org.mockito.BDDMockito.given;import static org.mockito.Mockito.verifyNoInteractions;import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.context.annotation.Import;import org.springframework.http.MediaType;import org.springframework.security.oauth2.jwt.BadJwtException;import org.springframework.security.oauth2.jwt.JwtDecoder;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.MockMvc;/** Who is let in, and who is turned away. The services are mocks, so only the security rules are tested here. */@WebMvcTest(controllers = {JobApplicationController.class, AuthController.class},        properties = {"security.jwt-secret=c2VjcmV0LWZvci10ZXN0cy1vbmx5LTMyLWJ5dGVzLWxvbmch",                "security.allowed-origins=https://app.example.com"})@Import(SecurityTestConfig.class)class SecurityRulesTest {    @Autowired    MockMvc mvc;    @MockitoBean    JobApplicationService applications;    @MockitoBean    AuthService auth;    @MockitoBean    JwtDecoder jwtDecoder;    @Test    void aRequestWithoutATokenIsRefusedWith401AndAProblemBody() throws Exception {        mvc.perform(get("/api/applications"))                .andExpect(status().isUnauthorized())                .andExpect(header().exists("WWW-Authenticate"))                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))                .andExpect(jsonPath("$.title").value("Unauthorized"));        verifyNoInteractions(applications);    }    @Test    void aTokenThatFailsVerificationIsRefusedWith401() throws Exception {        given(jwtDecoder.decode("forged")).willThrow(new BadJwtException("signature does not match"));        mvc.perform(get("/api/applications").header("Authorization", "Bearer forged"))                .andExpect(status().isUnauthorized());        verifyNoInteractions(applications);    }    @Test    void aValidTokenIsLetThrough() throws Exception {        given(applications.list(any(), any(), any(), any(), any())).willReturn(                new PageResponse<>(java.util.List.of(), 0, 20, 0, 0));        mvc.perform(get("/api/applications").with(jwt().jwt(token -> token.subject("1"))))                .andExpect(status().isOk());    }    @Test    void registeringAndLoggingInNeedNoToken() throws Exception {        given(auth.register(any())).willReturn(new UserResponse(1L, "ada@example.com"));        mvc.perform(post("/api/auth/register").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"ada@example.com\", \"password\": \"a long enough password\"}"))                .andExpect(status().isCreated());    }    @Test    void aShortPasswordIsRejectedBeforeItReachesTheService() throws Exception {        mvc.perform(post("/api/auth/register").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"ada@example.com\", \"password\": \"short\"}"))                .andExpect(status().isBadRequest())                .andExpect(jsonPath("$.errors.password").exists());        verifyNoInteractions(auth);    }    @Test    void everythingElseIsDeniedByDefault() throws Exception {        mvc.perform(get("/actuator/env")).andExpect(status().isUnauthorized());        mvc.perform(get("/anything-at-all")).andExpect(status().isUnauthorized());    }    @Test    void aBrowserPageFromAnAllowedOriginPassesThePreflightCheck() throws Exception {        mvc.perform(options("/api/applications")                        .header("Origin", "https://app.example.com")                        .header("Access-Control-Request-Method", "POST")                        .header("Access-Control-Request-Headers", "authorization,content-type"))                .andExpect(status().isOk())                .andExpect(header().string("Access-Control-Allow-Origin", "https://app.example.com"));    }    @Test    void aPageFromAnyOtherOriginIsRefused() throws Exception {        mvc.perform(options("/api/applications")                        .header("Origin", "https://evil.example.net")                        .header("Access-Control-Request-Method", "POST"))                .andExpect(status().isForbidden());    }}
  • TokenServiceTest checks the token itself without Spring: it has the right subject, issuer and lifetime; a token with a changed payload is rejected; a token signed with another secret is rejected; an expired token is rejected; and a missing, short or non-Base64 secret stops start-up with the right message:
Java
// File: src/test/java/com/example/jobtracker/TokenServiceTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.lang.reflect.Field;import java.time.Clock;import java.time.Duration;import java.util.Base64;import java.util.List;import javax.crypto.SecretKey;import org.junit.jupiter.api.Test;import org.springframework.security.oauth2.jwt.Jwt;import org.springframework.security.oauth2.jwt.JwtDecoder;import org.springframework.security.oauth2.jwt.JwtException;/** The token machinery without Spring: what is in a token, and what the decoder refuses. */class TokenServiceTest {    private static final String SECRET = Base64.getEncoder().encodeToString(            "a-test-secret-of-exactly-32-byte".getBytes(java.nio.charset.StandardCharsets.UTF_8));    private static final String OTHER_SECRET = Base64.getEncoder().encodeToString(            "another-secret-of-32-bytes-long!".getBytes(java.nio.charset.StandardCharsets.UTF_8));    private static SecuritySettings settings(String secret, Duration lifetime) {        return new SecuritySettings(secret, lifetime, List.of("http://localhost:3000"));    }    private static TokenService tokenService(String secret, Duration lifetime, Clock clock) {        JwtConfig config = new JwtConfig();        SecuritySettings settings = settings(secret, lifetime);        return new TokenService(config.jwtEncoder(config.jwtKey(settings)), settings, clock);    }    private static JwtDecoder decoder(String secret) {        JwtConfig config = new JwtConfig();        SecretKey key = config.jwtKey(settings(secret, Duration.ofHours(1)));        return config.jwtDecoder(key);    }    private static AppUser user(long id) throws Exception {        AppUser user = new AppUser("ada@example.com", "{bcrypt}x", java.time.Instant.now());        Field field = AppUser.class.getDeclaredField("id");        field.setAccessible(true);        field.set(user, id);        return user;    }    @Test    void aTokenNamesTheUserAndExpiresAfterTheConfiguredLifetime() throws Exception {        TokenResponse response = tokenService(SECRET, Duration.ofHours(1), Clock.systemUTC()).issueFor(user(42));        Jwt token = decoder(SECRET).decode(response.accessToken());        assertEquals("Bearer", response.tokenType());        assertEquals(3600, response.expiresInSeconds());        assertEquals("42", token.getSubject());        assertEquals("jobtracker", token.getClaimAsString("iss"));        assertEquals(Duration.ofHours(1), Duration.between(token.getIssuedAt(), token.getExpiresAt()));    }    @Test    void aTokenChangedByAnAttackerIsRejected() throws Exception {        String token = tokenService(SECRET, Duration.ofHours(1), Clock.systemUTC()).issueFor(user(42)).accessToken();        String[] parts = token.split("\\.");        String forgedPayload = Base64.getUrlEncoder().withoutPadding()                .encodeToString("{\"sub\":\"1\",\"iss\":\"jobtracker\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8));        String forged = parts[0] + "." + forgedPayload + "." + parts[2];       // a new payload with the old signature        assertThrows(JwtException.class, () -> decoder(SECRET).decode(forged));    }    @Test    void aTokenSignedWithAnotherSecretIsRejected() throws Exception {        String token = tokenService(OTHER_SECRET, Duration.ofHours(1), Clock.systemUTC()).issueFor(user(42)).accessToken();        assertThrows(JwtException.class, () -> decoder(SECRET).decode(token));    }    @Test    void anExpiredTokenIsRejected() throws Exception {        Clock twoHoursAgo = Clock.fixed(java.time.Instant.now().minus(Duration.ofHours(2)), java.time.ZoneOffset.UTC);        String token = tokenService(SECRET, Duration.ofHours(1), twoHoursAgo).issueFor(user(42)).accessToken();   // expired an hour ago        JwtException e = assertThrows(JwtException.class, () -> decoder(SECRET).decode(token));        assertTrue(e.getMessage().contains("expired"), e.getMessage());    }    @Test    void aSecretThatIsTooShortStopsTheApplicationFromStarting() {        String shortSecret = Base64.getEncoder().encodeToString("too short".getBytes(java.nio.charset.StandardCharsets.UTF_8));        IllegalStateException e = assertThrows(IllegalStateException.class,                () -> new JwtConfig().jwtKey(settings(shortSecret, Duration.ofHours(1))));        assertEquals("security.jwt-secret must decode to at least 32 bytes, but it decodes to 9", e.getMessage());    }    @Test    void aMissingSecretStopsTheApplicationWithAnInstruction() {        IllegalStateException e = assertThrows(IllegalStateException.class,                () -> new JwtConfig().jwtKey(settings("${JWT_SECRET}", Duration.ofHours(1))));        assertTrue(e.getMessage().contains("JWT_SECRET"), e.getMessage());    }    @Test    void aSecretThatIsNotBase64StopsTheApplicationFromStarting() {        assertThrows(IllegalStateException.class,                () -> new JwtConfig().jwtKey(settings("this is not base64 !!!", Duration.ofHours(1))));    }}
  • AuthServiceTest checks the password handling with the real encoder: only a bcrypt hash is stored (never the password, with the {bcrypt} prefix), two identical passwords give different hashes, a duplicate is rejected without saving, and wrong password and unknown email fail identically:
Java
// File: src/test/java/com/example/jobtracker/AuthServiceTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertNotEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import static org.mockito.ArgumentMatchers.any;import static org.mockito.BDDMockito.given;import static org.mockito.Mockito.never;import static org.mockito.Mockito.verify;import java.time.Clock;import java.time.Instant;import java.time.ZoneOffset;import java.util.Optional;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.ArgumentCaptor;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import org.springframework.security.crypto.factory.PasswordEncoderFactories;import org.springframework.security.crypto.password.PasswordEncoder;/** Registration and login with a real password encoder and a mocked user store. */@ExtendWith(MockitoExtension.class)class AuthServiceTest {    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);    @Mock    AppUserRepository users;    @Mock    TokenService tokens;    private final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();    private AuthService service;    @BeforeEach    void setUp() {        service = new AuthService(users, encoder, tokens, CLOCK);    }    @Test    void registerStoresAHashAndNeverThePassword() {        given(users.existsByEmail("ada@example.com")).willReturn(false);        given(users.saveAndFlush(any(AppUser.class))).willAnswer(call -> call.getArgument(0));        service.register(new RegisterRequest("  Ada@Example.COM ", "correct horse battery"));        ArgumentCaptor<AppUser> saved = ArgumentCaptor.forClass(AppUser.class);        verify(users).saveAndFlush(saved.capture());        String hash = saved.getValue().getPasswordHash();        assertEquals("ada@example.com", saved.getValue().getEmail());          // trimmed and lower-cased        assertNotEquals("correct horse battery", hash);        assertFalse(hash.contains("correct horse"));        assertTrue(hash.startsWith("{bcrypt}"), hash);        assertTrue(encoder.matches("correct horse battery", hash));    }    @Test    void twoUsersWithTheSamePasswordGetDifferentHashes() {        String first = encoder.encode("correct horse battery");        String second = encoder.encode("correct horse battery");        assertNotEquals(first, second);                                          // each hash has its own random salt        assertTrue(encoder.matches("correct horse battery", first));        assertTrue(encoder.matches("correct horse battery", second));    }    @Test    void registeringAnEmailThatExistsIsRejectedAndNothingIsSaved() {        given(users.existsByEmail("ada@example.com")).willReturn(true);        assertThrows(EmailAlreadyUsedException.class,                () -> service.register(new RegisterRequest("ada@example.com", "correct horse battery")));        verify(users, never()).saveAndFlush(any());    }    @Test    void loginWithTheRightPasswordIssuesAToken() {        AppUser ada = new AppUser("ada@example.com", encoder.encode("correct horse battery"), Instant.now());        given(users.findByEmail("ada@example.com")).willReturn(Optional.of(ada));        TokenResponse token = new TokenResponse("the-token", "Bearer", 3600);        given(tokens.issueFor(ada)).willReturn(token);        assertEquals(token, service.login(new LoginRequest("Ada@example.com", "correct horse battery")));    }    @Test    void aWrongPasswordAndAnUnknownEmailGiveTheSameFailure() {        AppUser ada = new AppUser("ada@example.com", encoder.encode("correct horse battery"), Instant.now());        given(users.findByEmail("ada@example.com")).willReturn(Optional.of(ada));        given(users.findByEmail("nobody@example.com")).willReturn(Optional.empty());        InvalidCredentialsException wrongPassword = assertThrows(InvalidCredentialsException.class,                () -> service.login(new LoginRequest("ada@example.com", "wrong password here")));        InvalidCredentialsException unknownEmail = assertThrows(InvalidCredentialsException.class,                () -> service.login(new LoginRequest("nobody@example.com", "whatever it was")));        assertEquals(wrongPassword.getMessage(), unknownEmail.getMessage());     // the response must not reveal which one        verify(tokens, never()).issueFor(any());    }}
  • SecurityIntegrationTest is the whole stack, with the real filters, controllers, services, tokens and PostgreSQL. It registers, logs in and uses the API; checks that the stored password is a hash; and, the key test, that one user cannot see another's application even by guessing its id:
Java
// File: src/test/java/com/example/jobtracker/SecurityIntegrationTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;import com.jayway.jsonpath.JsonPath;import org.junit.jupiter.api.AfterEach;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;import org.springframework.http.MediaType;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.test.context.DynamicPropertyRegistry;import org.springframework.test.context.DynamicPropertySource;import org.springframework.test.web.servlet.MockMvc;/** The whole stack: real security filters, real controllers and services, real PostgreSQL, real tokens. */@SpringBootTest(properties = "security.jwt-secret=c2VjcmV0LWZvci10ZXN0cy1vbmx5LTMyLWJ5dGVzLWxvbmch")@AutoConfigureMockMvcclass SecurityIntegrationTest {    private static final String PASSWORD = "correct horse battery";    @DynamicPropertySource    static void database(DynamicPropertyRegistry registry) {        TestPostgres.register(registry);    }    @Autowired    MockMvc mvc;    @Autowired    JdbcTemplate jdbc;    @AfterEach    void cleanUp() {        jdbc.update("delete from interviews");        jdbc.update("delete from job_applications");        jdbc.update("delete from users");    }    private void register(String email) throws Exception {        mvc.perform(post("/api/auth/register").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"" + email + "\", \"password\": \"" + PASSWORD + "\"}"))                .andExpect(status().isCreated());    }    private String login(String email, String password) throws Exception {        String body = mvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"" + email + "\", \"password\": \"" + password + "\"}"))                .andExpect(status().isOk())                .andReturn().getResponse().getContentAsString();        return JsonPath.read(body, "$.accessToken");    }    private String createApplicationAs(String token) throws Exception {        String body = mvc.perform(post("/api/applications").header("Authorization", "Bearer " + token)                        .contentType(MediaType.APPLICATION_JSON)                        .content("{\"company\": \"Acme\", \"position\": \"Java Developer\"}"))                .andExpect(status().isCreated())                .andReturn().getResponse().getContentAsString();        return String.valueOf((Integer) JsonPath.read(body, "$.id"));    }    @Test    void registerLoginAndUseTheApiWithTheToken() throws Exception {        register("ada@example.com");        String token = login("ada@example.com", PASSWORD);        String id = createApplicationAs(token);        mvc.perform(get("/api/applications/" + id).header("Authorization", "Bearer " + token))                .andExpect(status().isOk())                .andExpect(jsonPath("$.company").value("Acme"));    }    @Test    void thePasswordIsStoredOnlyAsABcryptHash() throws Exception {        register("ada@example.com");        String stored = jdbc.queryForObject("select password_hash from users where email = 'ada@example.com'", String.class);        assertTrue(stored.startsWith("{bcrypt}$2"), stored);        assertFalse(stored.contains(PASSWORD));    }    @Test    void registeringTheSameEmailTwiceGives409() throws Exception {        register("ada@example.com");        mvc.perform(post("/api/auth/register").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"ADA@example.com\", \"password\": \"" + PASSWORD + "\"}"))                .andExpect(status().isConflict());    }    @Test    void aWrongPasswordAndAnUnknownUserBothGet401WithTheSameBody() throws Exception {        register("ada@example.com");        String wrongPassword = mvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"ada@example.com\", \"password\": \"not the password\"}"))                .andExpect(status().isUnauthorized()).andReturn().getResponse().getContentAsString();        String unknownUser = mvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)                        .content("{\"email\": \"nobody@example.com\", \"password\": \"not the password\"}"))                .andExpect(status().isUnauthorized()).andReturn().getResponse().getContentAsString();        String detailForWrongPassword = JsonPath.read(wrongPassword, "$.detail");        String detailForUnknownUser = JsonPath.read(unknownUser, "$.detail");        assertEquals(detailForWrongPassword, detailForUnknownUser);    }    @Test    void aUserCannotSeeAnotherUsersApplicationNotEvenByGuessingItsId() throws Exception {        register("ada@example.com");        register("grace@example.com");        String adasToken = login("ada@example.com", PASSWORD);        String gracesToken = login("grace@example.com", PASSWORD);        String adasApplication = createApplicationAs(adasToken);        mvc.perform(get("/api/applications/" + adasApplication).header("Authorization", "Bearer " + gracesToken))                .andExpect(status().isNotFound());        mvc.perform(get("/api/applications").header("Authorization", "Bearer " + gracesToken))                .andExpect(status().isOk())                .andExpect(jsonPath("$.totalItems").value(0));        mvc.perform(get("/api/applications").header("Authorization", "Bearer " + adasToken))                .andExpect(jsonPath("$.totalItems").value(1));    }    @Test    void aMissingOrBrokenTokenGets401() throws Exception {        mvc.perform(get("/api/applications")).andExpect(status().isUnauthorized());        mvc.perform(get("/api/applications").header("Authorization", "Bearer not.a.token"))                .andExpect(status().isUnauthorized());    }}

The other test classes were adjusted for ownership (every call now carries the owner), and JobApplicationRepositoryTest gained two tests: a search never returns another user's rows, and findByIdAndOwnerId finds an application only for its owner.

Review: what is not covered

#

Security is never finished. This is an honest list of what a production system adds:

Gap

What to do

Brute-force logins

Rate-limit or lock out repeated failures per account and per address

Refresh tokens and revocation

A long-lived refresh token and short access tokens, plus a way to revoke

Registration reveals that an email exists (409)

Accepted here for a clear message. High-security sites send an email instead

Password reset, email verification, MFA

Separate features with their own risks

Where the client keeps the token

In a browser, local storage is exposed to XSS and an HttpOnly cookie needs CSRF protection. Choose knowingly

HTTPS

The API assumes TLS in front of it. Without it, tokens and passwords cross the network in clear text

Audit logging

Record logins, failures and sensitive changes, without secrets

Dependency updates and scanning

Regularly update, and scan the dependencies for known vulnerabilities

Secret rotation

A plan to change the signing secret, and to support two secrets during the change

The dummy-hash timing defence is approximate

It evens out the main difference. Treat it as one layer

Common mistakes

#
  • Authorisation only in the UI, or checking that the user is logged in without checking that the data is theirs.
  • Trusting an id or a role sent by the client. Take identity from the verified token.
  • Fast hashes (MD5, SHA-1, SHA-256) or reversible encryption for passwords, and writing your own crypto.
  • Secrets in source code, in application.properties, in Git history, in logs or in error messages.
  • Returning different responses for "no such user" and "wrong password".
  • Long-lived tokens with no way to revoke them, or a weak or short signing secret.
  • Putting sensitive data in a JWT payload, because it is readable.
  • Disabling CSRF while using cookies, or leaving it on without understanding it for a token API.
  • allowedOrigins("*") with credentials, or thinking that CORS protects the API from non-browser clients.
  • Allow-by-default rules, and forgotten endpoints (debug, admin, metrics) left open.
  • Leaking details in errors: stack traces, SQL, "user not found".
  • Not testing the negative cases: another user's data, a forged token, an expired token.

Exercises

#

Exercise 1

Why not 403?

#

A user requests GET /api/applications/7, and application 7 belongs to someone else. This API returns 404. Argue for it, and say when you might return 403 instead.

Exercise 2: Read the token

#

A teammate says: "The role is in the JWT payload, and it is signed, so I will put the user's salary in there too." What is wrong?

Exercise 3: The leaked database

#

An attacker obtains a copy of the users table. What do they get if passwords were stored (a) in plain text, (b) as unsalted SHA-256, (c) as salted bcrypt? What is their best strategy in case (c)?

Exercise 4: CSRF or not?

#

A new feature stores the JWT in a cookie that the browser sends automatically. Does CSRF protection matter now? What changes?

Exercise 5: Fix the endpoint

#

A developer adds @GetMapping("/api/applications/{id}/export") which calls repository.findById(id) and returns the data. What is the vulnerability, and what is the fix?

Show solutionHide solution

A 403 says "this exists, but you may not have it", which confirms that id 7 exists. An attacker can walk through ids and learn how many records there are and which are taken. 404 gives the same answer for "does not exist" and "not yours", so nothing is revealed. You might return 403 when existence is not secret and a clear message helps the user, for example when a team member tries to open a project that is visible to the whole team but requires a higher role to edit. The invariant is that unauthorised data is never returned.

Exercise 2

Show solutionHide solution

A JWT is signed, not encrypted. Everyone who holds the token (the user, a browser extension, a proxy, a log file that records the header) can Base64-decode the payload and read the salary. The signature prevents changing the claims, not reading them. Put only the minimum in a token (a user id, an expiry), and fetch private data from the server after authorisation. If the content must be secret, use an encrypted token format (JWE), which is rarely necessary for an access token.

Exercise 3

Show solutionHide solution

(a) Everything: every password, immediately usable on this and other sites. (b) Almost everything: SHA-256 is fast and unsalted, so identical passwords look identical, and precomputed tables and billions of guesses a second recover most real-world passwords quickly. (c) They get the hashes but cannot reverse them. Each hash has its own salt, so there are no shortcuts across users, and the cost factor makes each guess slow. Their best strategy is a targeted guessing attack on one account at a time, hoping for a weak password, which is why long passwords matter. Bcrypt buys time; it does not make weak passwords safe.

Exercise 4

Show solutionHide solution

Yes, CSRF matters now. A cookie is attached automatically by the browser to every request to your site, including ones triggered by a malicious page, which is the condition CSRF exploits. With a bearer token in a header there is nothing automatic to abuse, so protection was unnecessary. With a cookie you need CSRF protection (an anti-CSRF token, plus SameSite cookie attributes), the HttpOnly and Secure flags on the cookie, and the CSRF filter must not be disabled.

Exercise 5

Show solutionHide solution

findById ignores ownership, so any authenticated user can export any user's application by changing the id: an insecure direct object reference. The fix is to use the owner-scoped lookup: pass the caller's id from the token, as the other endpoints do, through the service to findByIdAndOwnerId, so that the application of another user is simply not found (404). The structural lesson is that the repository should have no method that can fetch an application without an owner, so this mistake cannot be written. A test should assert that a second user gets 404 for that endpoint.

Where to go next

#

Sources & further reading

  1. docs.spring.io
  2. top10.owasp.org
  3. rfc-editor.org
  4. cheatsheetseries.owasp.org
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java