TUTORIAL

Spring Boot JWT & React

Build an authenticated application where each user can access only their own todo list.

Java 21Spring Boot 4.1.1Maven 3.9+MongoDB Atlas
How to use this manual: Read Spring foundations and complete Atlas setup once. Then follow the numbered steps here in order. Every source file is included below with a copy button, its exact relative path, and an explanation. Downloading the bundle is an alternative to creating the files by hand.

1. Understand the personal application

Each registered user has an independent todo list. React runs on http://localhost:5173; Spring Boot runs on port 8082 and connects to Atlas database todo_personal. A user registers, signs in, receives a JWT, and sends it with every todo request.

React → Bearer token filter → Controller: JWT subject → Owner-filtered service → Atlas

Authentication answers “Who is calling?” Authorization answers “Which data may they access?” A valid token alone does not authorize access to every todo. The repository must include ownerId in each query for a known task.

2. Understand passwords, JWT, CORS, and CSRF

A JWT has a header, payload, and signature separated by dots. Base64url encoding is readable; it is not encryption. This lab signs tokens with HS256 using a backend-only random secret. Signature verification detects modifications. exp limits lifetime, iss identifies this issuer, aud identifies this API, and sub identifies the user. A stolen bearer token can be used until expiration, so keep it out of URLs and logs.

BCrypt uses a salt and work factor to store a one-way password hash. During login, matches compares the supplied password against the stored hash. Each registration produces its own salt. The API never returns the user document, and it never puts a password or hash into JWT claims.

The backend uses Spring Security’s resource-server support to verify tokens; there is no custom token filter to maintain. The app also issues its own tokens for this example. It is not a full OAuth authorization server or OpenID Connect provider. The issuer is a local identifier, and the decoder is explicitly configured rather than using discovery.

CORS is the browser’s cross-origin response policy. localhost:5173 and localhost:8082 are different origins because ports differ. A request with Authorization or JSON can cause an OPTIONS preflight. Spring Security enables the configured CORS support so preflight processing can occur before authentication. Postman does not enforce browser CORS.

CSRF concerns credentials automatically attached by the browser, such as cookies. This API accepts an explicit Authorization bearer header and has no authentication cookie, so its stateless security chain disables CSRF. The Thymeleaf app uses forms and keeps CSRF protection. If you move authentication into cookies later, revisit CSRF handling.

Official references: JWT resource server, password storage, and CORS integration.

3. Create backend and frontend folders

Choose a working folder, such as C:\training, in which you can create projects. In the downloaded bundle, open projects/todo-personal directly in your IDE as a Maven project. To build from this manual instead, create a folder called todo-personal, then create every file shown in the next step at its exact path.

PowerShell / Bash · manual creation only
mkdir todo-personal
cd todo-personal

A directory separator may be / or \ on Windows. Match Java class names and filenames exactly. Do not place Java source files in the project root or inside resources. The first line of each Java file is package com.example.todo;.

Project paths · relative to todo-personal
pom.xml
src/main/java/com/example/todo/TodoApplication.java
src/main/resources/application.properties
.gitignore
src/main/java/com/example/todo/ApiErrors.java
src/main/java/com/example/todo/AppUser.java
src/main/java/com/example/todo/UserRepository.java
src/main/java/com/example/todo/PersonalTodo.java
src/main/java/com/example/todo/PersonalTodoRepository.java
src/main/java/com/example/todo/TodoRequest.java
src/main/java/com/example/todo/PersonalTodoService.java
src/main/java/com/example/todo/PersonalTodoController.java
src/main/java/com/example/todo/SecurityConfig.java
src/main/java/com/example/todo/AuthController.java
src/test/java/com/example/todo/OwnershipTest.java
src/test/java/com/example/todo/JwtTest.java
frontend/package.json
frontend/vite.config.js
frontend/index.html
frontend/src/main.jsx
frontend/src/App.jsx
frontend/src/style.css

Import or reload Maven after saving pom.xml. The package root is src/main/java/com/example/todo. Tests go under src/test/java/com/example/todo. Resources go under src/main/resources. No Lombok, code-generation step, or omitted helper class is required.

Node.js 22.12+ is required by the pinned Vite 7 tooling. This manual includes the entire frontend, so do not run create-vite over these files. Create frontend/src and save the listed frontend files directly.

4. Write every source file

pom.xml Download file

The parent pins Spring Boot 4.1.1 and manages compatible dependency versions. Web MVC supplies DispatcherServlet and embedded Tomcat. MongoDB supplies Spring Data repositories and the driver; validation supplies Jakarta Bean Validation. Java 21 is the example baseline. The Boot Maven plugin builds an executable JAR.

XML
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent>
  <groupId>com.example</groupId><artifactId>todo-personal</artifactId><version>1.0.0</version>
  <properties><java.version>21</java.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

src/main/java/com/example/todo/TodoApplication.java Download file

This main method starts the Spring container and the embedded web server. @SpringBootApplication combines configuration, auto-configuration, and component scanning. Keep every application class below com.example.todo so Spring discovers it.

Java
package com.example.todo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TodoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApplication.class, args);
    }
}

src/main/resources/application.properties Download file

Boot 4 uses spring.mongodb.uri and spring.mongodb.database. Environment placeholders keep credentials out of source. Each lab has its own database and port. Automatic index creation is enabled for the example database; personal users require a unique email index. In a deployed system, create and review indexes through a migration process.

Source
spring.application.name=todo-personal
server.port=8082
spring.mongodb.uri=${MONGODB_URI}
spring.mongodb.database=todo_personal
spring.data.mongodb.auto-index-creation=true
server.error.include-message=never
server.error.include-stacktrace=never
app.jwt.secret=${JWT_SECRET}
app.jwt.issuer=todo-training
app.jwt.audience=todo-api
app.jwt.ttl-seconds=900
app.cors.origin=http://localhost:5173

.gitignore Download file

Build output and local secret files must stay out of Git. An .env file is not automatically loaded by Spring Boot; use the documented terminal environment variables.

Source
target/
.env
*.log
node_modules/
dist/

src/main/java/com/example/todo/ApiErrors.java Download file

@RestControllerAdvice handles controller exceptions in one place. Field validation and JSON parse failures return 400; explicit status exceptions preserve their HTTP code. Database duplicate keys become 409 without exposing internal database messages. Security filter failures occur before controllers and use SecurityConfig’s handlers in the personal app.

Java
package com.example.todo;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@RestControllerAdvice
public class ApiErrors {
    private ResponseEntity<Map<String,Object>> error(int status,String message,Object details) {
        Map<String,Object> body=new LinkedHashMap<>();
        body.put("timestamp",Instant.now().toString()); body.put("status",status);
        body.put("message",message); body.put("details",details);
        return ResponseEntity.status(status).body(body);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<Map<String,Object>> validation(MethodArgumentNotValidException ex) {
        Map<String,String> fields=new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(e -> fields.put(e.getField(),e.getDefaultMessage()));
        return error(400,"Validation failed",fields);
    }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    ResponseEntity<Map<String,Object>> malformed(HttpMessageNotReadableException ex) {
        return error(400,"Malformed JSON or incorrect field type",Map.of());
    }
    @ExceptionHandler(ResponseStatusException.class)
    ResponseEntity<Map<String,Object>> status(ResponseStatusException ex) {
        return error(ex.getStatusCode().value(),ex.getReason(),Map.of());
    }
    @ExceptionHandler(DuplicateKeyException.class)
    ResponseEntity<Map<String,Object>> duplicate(DuplicateKeyException ex) {
        return error(409,"Email already registered",Map.of());
    }
}

src/main/java/com/example/todo/AppUser.java Download file

users stores a normalized email and a one-way password hash. The unique email index enforces uniqueness even when registrations race. This object is never returned by a controller: passwordHash must stay server-side. The id, not the email, becomes the stable JWT subject.

Java
package com.example.todo;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

@Document("users")
public class AppUser {
    @Id private String id;
    @Indexed(unique=true) private String email;
    private String passwordHash;
    public AppUser() {}
    public AppUser(String email,String passwordHash) { this.email=email; this.passwordHash=passwordHash; }
    public String getId() { return id; }
    public String getEmail() { return email; }
    public String getPasswordHash() { return passwordHash; }
}

src/main/java/com/example/todo/UserRepository.java Download file

Spring derives an equality query from findByEmail. Normalizing emails before storage and lookup gives consistent matching. The unique index supplies the final duplicate-registration safeguard.

Java
package com.example.todo;

import java.util.Optional;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface UserRepository extends MongoRepository<AppUser,String> {
    Optional<AppUser> findByEmail(String email);
}

src/main/java/com/example/todo/PersonalTodo.java Download file

Every personal todo carries ownerId. The owner index helps list queries. There is no public owner setter or owner field in the request DTO. Ownership is assigned from the verified token and remains fixed when the title or status changes.

Java
package com.example.todo;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

@Document("personal_todos")
public class PersonalTodo {
    @Id private String id;
    @Indexed private String ownerId;
    private String title;
    private boolean completed;
    public PersonalTodo() {}
    public PersonalTodo(String ownerId,String title,boolean completed) {
        this.ownerId=ownerId; this.title=title; this.completed=completed;
    }
    public String getId() { return id; }
    public String getOwnerId() { return ownerId; }
    public String getTitle() { return title; }
    public boolean isCompleted() { return completed; }
    public void setTitle(String title) { this.title=title; }
    public void setCompleted(boolean completed) { this.completed=completed; }
}

src/main/java/com/example/todo/PersonalTodoRepository.java Download file

The database query includes the authenticated owner, not merely a check in the React screen. findByIdAndOwnerId returns empty for missing and foreign-owned ids. Both become 404, limiting information about another user’s tasks. All read, update, and delete paths use this query.

Java
package com.example.todo;

import java.util.List;
import java.util.Optional;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface PersonalTodoRepository extends MongoRepository<PersonalTodo,String> {
    List<PersonalTodo> findByOwnerIdOrderByIdAsc(String ownerId);
    Optional<PersonalTodo> findByIdAndOwnerId(String id,String ownerId);
}

src/main/java/com/example/todo/TodoRequest.java Download file

The same API DTO as the REST lab permits title and completed only. React never controls ownerId.

Java
package com.example.todo;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record TodoRequest(
    @NotBlank @Size(max=120) String title,
    @NotNull Boolean completed
) {}

src/main/java/com/example/todo/PersonalTodoService.java Download file

The owner is a required argument for every operation. A malicious id cannot bypass authorization because update and delete load through the owner-filtered get. Creating a todo attaches the owner derived from JWT. This is the central authorization rule, independent of the UI.

Java
package com.example.todo;

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.NOT_FOUND;

@Service
public class PersonalTodoService {
    private final PersonalTodoRepository repository;
    public PersonalTodoService(PersonalTodoRepository repository) { this.repository=repository; }
    public List<PersonalTodo> list(String owner) { return repository.findByOwnerIdOrderByIdAsc(owner); }
    public PersonalTodo get(String id,String owner) {
        return repository.findByIdAndOwnerId(id,owner)
            .orElseThrow(() -> new ResponseStatusException(NOT_FOUND,"Todo not found"));
    }
    public PersonalTodo create(String owner,TodoRequest request) {
        return repository.save(new PersonalTodo(owner,request.title().strip(),request.completed()));
    }
    public PersonalTodo update(String id,String owner,TodoRequest request) {
        PersonalTodo todo=get(id,owner); todo.setTitle(request.title().strip());
        todo.setCompleted(request.completed()); return repository.save(todo);
    }
    public void delete(String id,String owner) { repository.delete(get(id,owner)); }
}

src/main/java/com/example/todo/PersonalTodoController.java Download file

Spring Security injects the verified Jwt through @AuthenticationPrincipal. jwt.getSubject() is the user’s database id from a signed claim. No owner parameter, email query string, or client-provided header is accepted as proof of ownership.

Java
package com.example.todo;

import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
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.*;

@RestController
@RequestMapping("/api/todos")
public class PersonalTodoController {
    private final PersonalTodoService service;
    public PersonalTodoController(PersonalTodoService service) { this.service=service; }
    @GetMapping public List<PersonalTodo> list(@AuthenticationPrincipal Jwt jwt) {
        return service.list(jwt.getSubject());
    }
    @GetMapping("/{id}") public PersonalTodo get(@PathVariable String id,@AuthenticationPrincipal Jwt jwt) {
        return service.get(id,jwt.getSubject());
    }
    @PostMapping public ResponseEntity<PersonalTodo> create(@AuthenticationPrincipal Jwt jwt,
        @Valid @RequestBody TodoRequest request) {
        PersonalTodo todo=service.create(jwt.getSubject(),request);
        return ResponseEntity.created(URI.create("/api/todos/"+todo.getId())).body(todo);
    }
    @PutMapping("/{id}") public PersonalTodo update(@PathVariable String id,
        @AuthenticationPrincipal Jwt jwt,@Valid @RequestBody TodoRequest request) {
        return service.update(id,jwt.getSubject(),request);
    }
    @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable String id,
        @AuthenticationPrincipal Jwt jwt) {
        service.delete(id,jwt.getSubject()); return ResponseEntity.noContent().build();
    }
}

src/main/java/com/example/todo/SecurityConfig.java Download file

BCrypt hashes passwords; it does not encrypt them for later recovery. The secret is Base64-encoded random key material and is used only on the backend. HS256 is explicitly selected for signing and verification. The decoder checks signature, issuer, timestamps, audience, subject, and presence of expiration. The resource-server filter performs verification before controllers. CORS allows exactly the React origin. CSRF is disabled here because authentication uses a bearer header and no authentication cookie; the MVC lab keeps CSRF. Stateless means the server does not use an HTTP session for login.

Java
package com.example.todo;

import java.util.Base64;
import java.util.List;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.*;

@Configuration
public class SecurityConfig {
    @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
    @Bean SecretKey jwtKey(@Value("${app.jwt.secret}") String base64) {
        byte[] bytes=Base64.getDecoder().decode(base64);
        if(bytes.length<32) throw new IllegalArgumentException("JWT_SECRET must decode to at least 32 random bytes");
        return new SecretKeySpec(bytes,"HmacSHA256");
    }
    @Bean JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }
    @Bean JwtDecoder jwtDecoder(SecretKey key,@Value("${app.jwt.issuer}") String issuer,
                               @Value("${app.jwt.audience}") String audience) {
        NimbusJwtDecoder decoder=NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
        OAuth2TokenValidator<Jwt> claims=jwt -> {
            boolean valid=jwt.getAudience().contains(audience) && jwt.getSubject()!=null
                && !jwt.getSubject().isBlank() && jwt.getExpiresAt()!=null;
            return valid ? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(
                new OAuth2Error("invalid_token","Required claims missing or invalid",null));
        };
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer(issuer),claims));
        return decoder;
    }
    @Bean CorsConfigurationSource corsConfigurationSource(@Value("${app.cors.origin}") String origin) {
        CorsConfiguration config=new CorsConfiguration();
        config.setAllowedOrigins(List.of(origin));
        config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization","Content-Type"));
        config.setExposedHeaders(List.of("Location"));
        UrlBasedCorsConfigurationSource source=new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",config); return source;
    }
    @Bean SecurityFilterChain api(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(a -> a
                .requestMatchers(HttpMethod.POST,"/api/auth/register","/api/auth/login").permitAll()
                .requestMatchers("/api/todos/**").authenticated()
                .anyRequest().denyAll())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())
                .authenticationEntryPoint((request,response,ex) -> {
                    response.setStatus(401); response.setHeader("WWW-Authenticate","Bearer");
                    response.setContentType("application/json");
                    response.getWriter().write("{\"status\":401,\"message\":\"Authentication required or token invalid\"}");
                }))
            .exceptionHandling(e -> e.accessDeniedHandler((request,response,ex) -> {
                response.setStatus(403); response.setContentType("application/json");
                response.getWriter().write("{\"status\":403,\"message\":\"Access denied\"}");
            }));
        return http.build();
    }
}

src/main/java/com/example/todo/AuthController.java Download file

Register validates and normalizes email, hashes the password, and inserts a user. It never stores the plaintext password. Login uses the same normalization and BCrypt matches; unknown-user and wrong-password errors share a generic message. A dummy hash avoids skipping BCrypt work for unknown emails. BCrypt has a 72-byte input limit, so UTF-8 byte length is checked explicitly. Login emits a signed 15-minute JWT with sub, iss, aud, iat, and exp. JWT payloads are readable, so passwords and hashes are never claims. Registration does not automatically log in.

Java
package com.example.todo;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    public record Credentials(@NotBlank @Email @Size(max=254) String email,
        @NotBlank @Size(min=8,max=72) String password) {}
    public record TokenResponse(String accessToken,String tokenType,long expiresIn,String email) {}
    private final UserRepository users;
    private final PasswordEncoder passwords;
    private final JwtEncoder encoder;
    private final String issuer,audience;
    private final long ttl;
    private final String dummyHash;
    public AuthController(UserRepository users,PasswordEncoder passwords,JwtEncoder encoder,
        @Value("${app.jwt.issuer}") String issuer,@Value("${app.jwt.audience}") String audience,
        @Value("${app.jwt.ttl-seconds}") long ttl) {
        this.users=users; this.passwords=passwords; this.encoder=encoder;
        this.issuer=issuer; this.audience=audience; this.ttl=ttl;
        this.dummyHash=passwords.encode("dummy-password-not-an-account");
    }
    private String email(Credentials c) { return c.email().strip().toLowerCase(Locale.ROOT); }
    private void checkPasswordBytes(String password) {
        if(password.getBytes(StandardCharsets.UTF_8).length>72)
            throw new ResponseStatusException(BAD_REQUEST,"Password must be at most 72 UTF-8 bytes");
    }
    @PostMapping("/register") public ResponseEntity<Map<String,String>> register(@Valid @RequestBody Credentials c) {
        checkPasswordBytes(c.password());
        users.save(new AppUser(email(c),passwords.encode(c.password())));
        return ResponseEntity.status(CREATED).body(Map.of("message","Registered. Sign in now."));
    }
    @PostMapping("/login") public TokenResponse login(@Valid @RequestBody Credentials c) {
        checkPasswordBytes(c.password());
        AppUser user=users.findByEmail(email(c)).orElse(null);
        boolean matches=passwords.matches(c.password(),user==null ? dummyHash : user.getPasswordHash());
        if(user==null || !matches) throw new ResponseStatusException(UNAUTHORIZED,"Invalid email or password");
        Instant now=Instant.now();
        JwtClaimsSet claims=JwtClaimsSet.builder().issuer(issuer).subject(user.getId())
            .audience(List.of(audience)).issuedAt(now).expiresAt(now.plusSeconds(ttl)).build();
        String token=encoder.encode(JwtEncoderParameters.from(
            JwsHeader.with(MacAlgorithm.HS256).build(),claims)).getTokenValue();
        return new TokenResponse(token,"Bearer",ttl,user.getEmail());
    }
}

src/test/java/com/example/todo/OwnershipTest.java Download file

These tests verify the authorization boundary: a foreign id cannot be read, updated, or deleted, and create uses the authenticated owner. The manual also includes a two-user HTTP test to verify the full request path against Atlas.

Java
package com.example.todo;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class OwnershipTest {
    @Test void foreignTodoCannotBeReadUpdatedOrDeleted() {
        PersonalTodoRepository repo=mock(PersonalTodoRepository.class);
        when(repo.findByIdAndOwnerId("alice-task","bob")).thenReturn(Optional.empty());
        PersonalTodoService service=new PersonalTodoService(repo);
        assertEquals(404,assertThrows(ResponseStatusException.class,() -> service.get("alice-task","bob")).getStatusCode().value());
        assertThrows(ResponseStatusException.class,() -> service.update("alice-task","bob",new TodoRequest("Hacked",true)));
        assertThrows(ResponseStatusException.class,() -> service.delete("alice-task","bob"));
        verify(repo,never()).save(any()); verify(repo,never()).delete(any(PersonalTodo.class));
        verify(repo,never()).findById(anyString());
    }
    @Test void newTodoAlwaysUsesAuthenticatedOwner() {
        PersonalTodoRepository repo=mock(PersonalTodoRepository.class);
        when(repo.save(any(PersonalTodo.class))).thenAnswer(i -> i.getArgument(0));
        PersonalTodo todo=new PersonalTodoService(repo).create("alice",new TodoRequest(" Plan ",false));
        assertEquals("alice",todo.getOwnerId()); assertEquals("Plan",todo.getTitle());
    }
}

src/test/java/com/example/todo/JwtTest.java Download file

JWT tests check acceptance, wrong issuer, wrong audience, expired tokens, and a mismatched key. The all-zero key exists only inside this isolated test; the running application requires a randomly generated key.

Java
package com.example.todo;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import static org.junit.jupiter.api.Assertions.*;
class JwtTest {
    final SecurityConfig config=new SecurityConfig();
    final SecretKey key=config.jwtKey(Base64.getEncoder().encodeToString(new byte[32]));
    String token(String issuer,String audience,Instant expires) {
        JwtClaimsSet claims=JwtClaimsSet.builder().issuer(issuer).subject("alice")
            .audience(List.of(audience)).issuedAt(Instant.now().minusSeconds(1000)).expiresAt(expires).build();
        return config.jwtEncoder(key).encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(),claims)).getTokenValue();
    }
    @Test void validTokenAccepted() {
        assertEquals("alice",config.jwtDecoder(key,"todo-training","todo-api")
            .decode(token("todo-training","todo-api",Instant.now().plusSeconds(900))).getSubject());
    }
    @Test void invalidClaimsAndSignatureRejected() {
        JwtDecoder decoder=config.jwtDecoder(key,"todo-training","todo-api");
        assertThrows(JwtException.class,() -> decoder.decode(token("other","todo-api",Instant.now().plusSeconds(900))));
        assertThrows(JwtException.class,() -> decoder.decode(token("todo-training","other",Instant.now().plusSeconds(900))));
        assertThrows(JwtException.class,() -> decoder.decode(token("todo-training","todo-api",Instant.now().minusSeconds(300))));
        byte[] other=new byte[32]; other[0]=1;
        JwtDecoder wrongKey=config.jwtDecoder(config.jwtKey(Base64.getEncoder().encodeToString(other)),"todo-training","todo-api");
        assertThrows(JwtException.class,() -> wrongKey.decode(token("todo-training","todo-api",Instant.now().plusSeconds(900))));
    }
}

frontend/package.json Download file

Pinned dependencies make the development setup predictable. npm install produces package-lock.json; keep that file and use npm ci for later identical installs. Vite is the development server and production bundler. This is a fixed example baseline, not a claim to use every latest package.

Source
{
  "name": "personal-todo-ui",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.1.1",
    "react-dom": "19.1.1"
  },
  "devDependencies": {
    "vite": "7.1.7",
    "@vitejs/plugin-react": "5.0.3"
  }
}

frontend/vite.config.js Download file

The React plugin transforms JSX. A fixed dev port keeps the origin aligned with the backend CORS configuration.

Source
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });

frontend/index.html Download file

Vite serves this entry point. React mounts into root. Do not open this application HTML as a file; run the Vite server.

HTML
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>My personal todos</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>

frontend/src/main.jsx Download file

createRoot starts the React tree. StrictMode adds development checks. React handles browser rendering; Spring Boot handles API and database operations.

JSX
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);

frontend/src/App.jsx Download file

useState holds form, list, login, and feedback state. Controlled inputs copy browser changes into state. fetch sends JSON and a bearer header. Tokens stay in memory and are cleared on logout, refresh, and expiration; there is no localStorage persistence. useEffect schedules expiration cleanup. A ref prevents an old response from repopulating a later session. Errors and network failures are displayed and busy state prevents repeated mutations. Rendering escaped text through JSX helps avoid HTML injection.

JSX
import { useEffect, useRef, useState } from 'react';
const BASE = 'http://localhost:8082';

export default function App() {
  const [session, setSession] = useState(null);
  const [mode, setMode] = useState('login');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [todos, setTodos] = useState([]);
  const [title, setTitle] = useState('');
  const [editing, setEditing] = useState(null);
  const [completed, setCompleted] = useState(false);
  const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false);
  const sessionRef = useRef(null);
  function loginSession(value) { sessionRef.current = value; setSession(value); }
  function logout() {
    sessionRef.current = null; setSession(null); setTodos([]); setTitle('');
    setEditing(null); setCompleted(false); setPassword('');
  }
  async function api(route, method = 'GET', body, current = sessionRef.current) {
    const response = await fetch(BASE + route, {
      method,
      headers: {
        ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
        ...(current ? { Authorization: 'Bearer ' + current.accessToken } : {})
      },
      ...(body !== undefined ? { body: JSON.stringify(body) } : {})
    });
    const text = await response.text();
    let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { data = { message: text }; }
    if (!response.ok) {
      if (response.status === 401 && current && sessionRef.current === current) logout();
      const details = data.details ? Object.entries(data.details).map(([k,v]) => k + ': ' + v).join('; ') : '';
      throw new Error([data.message || ('HTTP ' + response.status), details].filter(Boolean).join(' — '));
    }
    return data;
  }
  async function refresh(current = sessionRef.current) {
    const items = await api('/api/todos', 'GET', undefined, current);
    if (sessionRef.current === current) setTodos(items);
  }
  useEffect(() => {
    if (!session) return;
    const remaining = session.expiresAt - Date.now();
    const timeout = setTimeout(() => { logout(); setMessage('Session expired. Sign in again.'); }, Math.max(0, remaining));
    return () => clearTimeout(timeout);
  }, [session]);
  async function authenticate(event) {
    event.preventDefault(); setBusy(true); setMessage('');
    try {
      if (mode === 'register') {
        await api('/api/auth/register', 'POST', { email, password }, null);
        setMode('login'); setPassword(''); setMessage('Registered. Sign in with your password.');
      } else {
        const data = await api('/api/auth/login', 'POST', { email, password }, null);
        const current = { ...data, expiresAt: Date.now() + data.expiresIn * 1000 };
        loginSession(current); setPassword(''); await refresh(current);
      }
    } catch (error) { setMessage(error.message); } finally { setBusy(false); }
  }
  async function save(event) {
    event.preventDefault(); setBusy(true); setMessage('');
    try {
      await api(editing ? '/api/todos/' + editing : '/api/todos', editing ? 'PUT' : 'POST', { title, completed });
      setTitle(''); setCompleted(false); setEditing(null); await refresh();
    } catch (error) { setMessage(error.message); } finally { setBusy(false); }
  }
  async function remove(id) {
    setBusy(true); setMessage('');
    try {
      await api('/api/todos/' + id, 'DELETE');
      if (editing === id) { setEditing(null); setTitle(''); setCompleted(false); }
      await refresh();
    } catch (error) { setMessage(error.message); } finally { setBusy(false); }
  }
  return <main>
    <h1>My personal todos</h1>
    <p role="status">{message}</p>
    {!session ? <>
      <h2>{mode === 'login' ? 'Sign in' : 'Register'}</h2>
      <form onSubmit={authenticate}>
        <label>Email<input type="email" required maxLength={254} value={email} onChange={e => setEmail(e.target.value)} autoComplete="username" /></label>
        <label>Password<input type="password" required minLength={8} maxLength={72} value={password} onChange={e => setPassword(e.target.value)} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} /></label>
        <button disabled={busy}>{busy ? 'Please wait…' : mode === 'login' ? 'Sign in' : 'Create account'}</button>
      </form>
      <button disabled={busy} onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setMessage(''); setPassword(''); }}>
        {mode === 'login' ? 'Need an account? Register' : 'Already registered? Sign in'}
      </button>
    </> : <>
      <p>Signed in as {session.email}</p><button disabled={busy} onClick={() => { logout(); setMessage('Signed out.'); }}>Sign out</button>
      <h2>{editing ? 'Edit todo' : 'Add todo'}</h2>
      <form onSubmit={save}>
        <label>Title<input required maxLength={120} value={title} onChange={e => setTitle(e.target.value)} /></label>
        <label><input type="checkbox" checked={completed} onChange={e => setCompleted(e.target.checked)} /> Completed</label>
        <button disabled={busy}>Save</button>
        {editing && <button type="button" disabled={busy} onClick={() => { setEditing(null); setTitle(''); setCompleted(false); }}>Cancel</button>}
      </form>
      <button disabled={busy} onClick={async () => { setBusy(true); try { await refresh(); setMessage(''); } catch(e) { setMessage(e.message); } finally { setBusy(false); } }}>Refresh</button>
      {todos.length === 0 ? <p>No tasks yet.</p> : <ul>{todos.map(todo => <li key={todo.id}>
        <span>{todo.title} — {todo.completed ? 'Done' : 'Pending'}</span>
        <button disabled={busy} onClick={() => { setEditing(todo.id); setTitle(todo.title); setCompleted(todo.completed); }}>Edit</button>
        <button disabled={busy} onClick={() => remove(todo.id)}>Delete</button>
      </li>)}</ul>}
    </>}
  </main>;
}

frontend/src/style.css Download file

The frontend stylesheet is imported by main.jsx and bundled by Vite.

Source
body{font-family:system-ui,sans-serif;background:#f2f6f3;color:#17352a;margin:0}main{max-width:850px;margin:40px auto;background:white;padding:32px;border-radius:12px}input,button{font:inherit;padding:9px;margin:6px}input:not([type=checkbox]){display:block;width:min(90%,450px)}button{cursor:pointer;background:#176746;color:white;border:0;border-radius:5px}table{width:100%;margin-top:24px;border-collapse:collapse}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}td form{display:inline}.error{color:#ae2525}a{color:#176746}
li{padding:14px;border-bottom:1px solid #ddd}button:disabled{opacity:.55;cursor:wait}

5. Generate the secret and start the backend

Set MONGODB_URI and JWT_SECRET in the backend terminal. The code below generates 32 random bytes and stores the Base64 encoding in the process environment without printing it. Do not replace it with a short phrase.

Windows PowerShell
# Backend terminal, in projects/todo-personal:
$env:MONGODB_URI = 'mongodb+srv://YOUR_DB_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority&authSource=admin'
$jwtBytes = New-Object byte[] 32
$jwtRng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$jwtRng.GetBytes($jwtBytes)
$jwtRng.Dispose()
$env:JWT_SECRET = [Convert]::ToBase64String($jwtBytes)
# This creates a cryptographically random key without printing it.
mvn test
mvn spring-boot:run
Bash · requires openssl
# macOS / Linux Bash, in projects/todo-personal:
export MONGODB_URI='mongodb+srv://YOUR_DB_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority&authSource=admin'
export JWT_SECRET="$(openssl rand -base64 32)"
mvn test
mvn spring-boot:run

Look for Started TodoApplication and port 8082. Index creation may fail startup if Atlas is unreachable or the users collection already contains duplicate emails. Verify the unique email index in Atlas before registrations.

Keep the same JWT_SECRET in the backend terminal when restarting if you want existing tokens to remain verifiable until expiration. Regenerating it invalidates tokens signed with the previous key. All backend replicas would need the same managed key. Never copy it into React.

To package and run, use mvn clean package, then java -jar target/todo-personal-1.0.0.jar from the backend folder with both environment variables set.

6. Install and start React

Open a second terminal. Change into the frontend directory relative to the backend folder:

Terminal · run in a second window
cd frontend
npm install
npm run dev

On Windows, if your execution policy blocks npm.ps1, use npm.cmd install and npm.cmd run dev. The Vite command uses strictPort so a busy 5173 produces an error instead of silently switching to a CORS-mismatched port.

Open http://localhost:5173 exactly. Opening http://127.0.0.1:5173 creates a different browser origin; the backend currently allows localhost. Use the documented host, or change app.cors.origin deliberately.

Click “Need an account? Register”, use an email and a password with at least 8 characters, then sign in. Create a task, edit its title and completed checkbox, and delete it. Use the Refresh button to reload from the API.

Build the frontend separately using npm run build. The output is frontend/dist. This manual’s development setup uses two servers. Serving a built frontend from another origin requires updating CORS and its API URL configuration.

7. Trace the React workflow

Controlled inputs pair value with onChange; the browser edit updates useState. onSubmit prevents a full-page navigation and calls fetch. The api helper adds Content-Type only when there is a JSON body and Authorization only when a session exists.

Successful login stores accessToken, email, and expiresAt in memory. refresh obtains the owner-filtered list. After create, update, or delete, the list is fetched again. JSX escapes displayed title text. The component never hides a global list by filtering ownerId in the browser; the backend has already selected authorized data.

Tokens are deliberately held in memory. Refreshing the page signs the user out, and logout clears the current browser state. An expiry timer signs out after 15 minutes; a backend 401 also clears an active session. Expiration checking on the backend remains authoritative. A browser timer is only a usability aid.

The helper reads 204 as an empty response and displays validation details or HTTP errors. Network failures are caught and shown. A ref tracks the current session so a stale response from an earlier session cannot repopulate its tasks.

React reference: React Quick Start. Component state, events, and list keys are the main concepts used here.

8. Test authentication and ownership in Postman

Import the personal collection and select the Todo local environment. baseAuthUrl must be http://localhost:8082. The collection creates unique run-specific Alice and Bob email values, stores both tokens, and verifies cross-user access denial.

Method / URLBody / authorizationExpected
POST /api/auth/registeremail and password; no token201 registration message; 409 duplicate email
POST /api/auth/loginemail and password; no token200 TokenResponse; 401 wrong credentials
GET /api/todosAuthorization: Bearer <accessToken>200 · only that user’s tasks
POST /api/todostitle and completed + token201 · server-assigned owner
GET / PUT / DELETE /api/todos/{id}Valid user token200 / 200 / 204 for owned id; 404 for another user’s id

8.1 Register Alice

Postman request
POST http://localhost:8082/api/auth/register
Content-Type: application/json

{"email":"alice@example.com","password":"AlicePass123!"}

Expect 201. A second registration with the same normalized email returns 409. Use another fictitious address if this account already exists in your example database.

8.2 Sign in

Postman request
POST http://localhost:8082/api/auth/login
Content-Type: application/json

{"email":"alice@example.com","password":"AlicePass123!"}
Response shape
{"accessToken":"SIGNED_JWT_VALUE","tokenType":"Bearer","expiresIn":900,"email":"alice@example.com"}

In Postman Authorization → Bearer Token, paste the accessToken only; the UI adds Bearer. In a manually constructed header, use Authorization: Bearer followed by a space and the token. Do not send the token in a query string.

8.3 Create Alice’s task

Postman request
POST http://localhost:8082/api/todos
Authorization: Bearer ALICE_TOKEN
Content-Type: application/json

{"title":"Alice private task","completed":false}

Save the returned id. Register and log in as Bob with a different email. Bob’s list must not contain Alice’s task. With Bob’s token, GET, PUT, and DELETE against Alice’s id must all return 404. Afterwards, Alice’s GET must still return her unchanged task.

9. Execute the security and failure checklist

  1. Request GET /api/todos without a bearer token. Expect 401.
  2. Use malformed or tampered token text. Expect 401.
  3. Use a token signed with a different key. Expect 401; the included JWT unit test exercises this.
  4. Use expired, wrong-issuer, or wrong-audience tokens. Expect rejection; included unit tests check each.
  5. Sign in with a wrong password or unknown email. Expect the same 401 message.
  6. Register an already-used email with different case. Expect 409 because normalization and the unique index apply.
  7. Register a password whose UTF-8 encoding is longer than 72 bytes. Expect 400.
  8. Send whitespace title, missing completed, or malformed JSON with a valid token. Expect 400.
  9. Verify Bob cannot list, read, update, or delete Alice’s tasks. Verify Alice can still read and update her task.
  10. Inspect users in Atlas. Expect a BCrypt hash rather than the plaintext password; inspect the unique email index.
  11. Sign out in React. Expect the login screen. Refresh while signed in. Expect a fresh login screen because the token was held in memory.
  12. Wait for expiration, or temporarily reduce app.jwt.ttl-seconds to 30 for a demo, restart, and log in again. React should sign out. The decoder’s default timestamp skew may allow a recently expired token briefly in API tests; the unit test uses an expiration several minutes in the past.
  13. Restart both servers and sign in again. Expect personal tasks to persist.

10. Explain the deployment boundary

This code is complete for the local development example. Before exposing it to real users, add HTTPS, managed secrets and key rotation, login/registration rate limits, email verification and password reset, monitoring, pagination, and a tested recovery process. Keep MongoDB access tightly scoped. Serve production React assets from a deliberate deployment origin and update API/CORS configuration.

There is no refresh-token flow or server-side logout revocation in this example app. Logout removes the browser’s in-memory token; a copied access token remains valid until expiration. Introduce refresh-token rotation and revocation as a separate advanced module, or use an established identity provider. Never describe this logout as immediately revoking all existing tokens.

Read-then-save CRUD uses last-write-wins. Add @Version and conflict handling before relying on concurrent edits. A collection-wide list without pagination fits small example datasets; a production list needs bounds and query design.

Exercise: prove that adding an ownerId field to a malicious JSON body cannot grant access. The request DTO does not accept ownerId, and the service derives ownership from the verified subject. Then add pagination while preserving the owner predicate.

11. Troubleshooting

SymptomCause and fix
mvn is not recognizedInstall Maven and add its bin directory to PATH. Open a new terminal. Verify mvn -version.
Cannot resolve placeholder MONGODB_URISet the environment variable in the terminal that starts the app. IDE launches need an environment variable in their Run Configuration. Spring does not automatically read a .env file.
Connection points to localhostUse spring.mongodb.uri for this Boot 4 project; a Boot 3 spring.data.mongodb.uri property is not the property used here. Check spelling and active configuration.
Authentication failed / code 18Use the Atlas database user, not your Atlas website login. Reset its password if needed. Percent-encode URI reserved characters. Verify authSource=admin.
Server selection timeoutAdd your current public IP in Atlas Network Access; verify firewall access, active cluster, hostname, and outbound network connectivity. IP addresses can change on Wi-Fi or VPN.
UnknownHostException / SRV lookup failedRe-copy the actual hostname. SRV requires DNS support; corporate DNS may block it. Use the standard connection string provided by Atlas if your environment cannot resolve SRV.
Address already in useStop the old process with Ctrl+C or override SERVER_PORT. If changing the personal API port, change React BASE and the Postman baseUrl too.
HTTP 400Check the JSON syntax, Content-Type, title, and completed. Inspect details in the JSON error. In the MVC lab, inspect the form validation message.
HTTP 404Check route and id. In the personal app, a task owned by another user also returns 404.
Browser fails but Postman worksPostman does not enforce browser CORS. Use the documented frontend origin exactly and inspect the browser Network panel.
Dependency download failsCheck proxy settings, Maven settings.xml, and repository reachability. An offline build works only after dependencies have already been downloaded.

JWT_SECRET decode failure: use the Base64 random-key commands. Do not wrap the value in extra quotation marks inside an IDE environment field. 401 after restart: verify the same secret is still configured or sign in again. 403/CORS failure: open the exact http://localhost:5173 origin, inspect preflight, and verify app.cors.origin. Duplicate-key startup failure: resolve duplicate emails in the disposable example database so the unique index can be built.

Open the trainer guide →

Prepared 7 October 2026 · Java 21 · Spring Boot 4.1.1 · MongoDB Atlas
All code blocks are complete files or explicitly labelled commands. No real credentials are included.