Overview
The task manager API from the previous project has no concept of "whose" task anything is — every client can see and change every record. A notes app fixes that by adding two things Spring Security exists for: authentication (proving who you are) and authorization scoped to that identity (only ever seeing your own data). Spring Security intercepts every incoming request through a chain of servlet filters before it ever reaches a controller, which is why a `SecurityFilterChain` bean, not a line inside `NoteController`, is where access rules actually live.
By the end of this tutorial you will have a `User` entity with a BCrypt-hashed password, a `Note` entity scoped to its owning user, HTTP Basic authentication wired through a custom `UserDetailsService`, and a service layer that reads `SecurityContextHolder` to make sure one user's notes are never visible or editable by another. Every note lookup below is filtered by the authenticated principal's username — not by trusting an id the client sent — which is the actual security boundary this project is built to demonstrate.
- A `User` entity with a unique `username` and a BCrypt-hashed `password`.
- A `Note` entity with a `@ManyToOne` relationship back to its owning `User`.
- A `CustomUserDetailsService` that loads a `User` from the database for Spring Security to authenticate against.
- A `SecurityFilterChain` bean requiring HTTP Basic authentication on every endpoint except registration.
- A `NoteService` that scopes every read and write to `SecurityContextHolder`'s authenticated principal.
- An `AuthController` for registration and a `NoteController` exposing per-user protected CRUD.
Prerequisites
- This course's Task Manager REST API project — entities, repositories, and a REST controller.
- HTTP basics — the `Authorization` header and HTTP status codes `200`, `201`, `401`, and `404`.
- Password hashing basics — why a password is hashed with `BCrypt` rather than encrypted or stored as plain text.
- Java `Optional` — `.map()`, `.filter()`, `.orElseThrow()`.
- A Spring Boot 3.x project with the Spring Web, Spring Data JPA, Spring Security, Validation, and H2 Database starters.
Project Structure
This project extends the layered layout from the previous one with a `security/` package: `src/main/java/com/programinds/notesapp/model/` holds `User` and `Note`; `repository/` holds `UserRepository` and `NoteRepository`; `security/` holds `CustomUserDetailsService` and `SecurityConfig`; `service/` holds `NoteService`; `dto/` holds `NoteRequest` and `RegisterRequest`; and `controller/` holds `AuthController` and `NoteController`. Keeping security configuration in its own package, separate from the controllers it protects, mirrors how a larger Spring Boot codebase isolates cross-cutting concerns from request-handling logic.
The critical design decision in this project is where per-user filtering happens: inside `NoteService`, not inside `NoteController`. Every controller method below simply calls a service method with no id-ownership logic of its own — `NoteService` is the single place that ever checks "does this note actually belong to the logged-in user," so that rule cannot accidentally be skipped by a future endpoint that forgets to repeat it.
Step 1: Define the User and Note Entities
`User` is mapped to a table named `app_user` rather than `user`, because `user` is a reserved word in several SQL dialects including PostgreSQL. `Note.owner` is a `@ManyToOne` with `fetch = FetchType.LAZY` — many notes can point at one user, and marking it lazy means loading a `Note` does not also eagerly load its owner's full row unless something actually calls `getOwner()`.
package com.programinds.notesapp.model;
import jakarta.persistence.*;
// Mapped to "app_user", not "user" — "user" is a reserved keyword in several// SQL dialects (including PostgreSQL), so the table name is changed explicitly.@Entity@Table(name = "app_user")public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(unique = true, nullable = false) // No two users may register the same username private String username;
@Column(nullable = false) private String password; // Stores a BCrypt hash produced in Step 6 — the raw password is never persisted
public Long getId() { return id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }}package com.programinds.notesapp.model;
import jakarta.persistence.*;
@Entitypublic class Note {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String content;
// Many notes belong to one user. LAZY means the owning User row is only // loaded from the database the moment something actually calls getOwner() — // listing 100 notes does not also trigger 100 extra user lookups. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_id", nullable = false) private User owner;
public Long getId() { return id; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public User getOwner() { return owner; }
public void setOwner(User owner) { this.owner = owner; }}Step 2: Create the Repositories
`findByUsername` on `UserRepository` is a Spring Data JPA derived query method — no `@Query` annotation, no SQL, just a method name Spring Data JPA parses at startup and generates a `WHERE username = ?` query for. `findByOwnerUsername` on `NoteRepository` does the same thing across the `@ManyToOne` relationship from Step 1, generating a query that joins `note` to `app_user` and filters by the owner's username.
package com.programinds.notesapp.repository;
import com.programinds.notesapp.model.User;import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { // Derived query method: Spring Data JPA parses the method name and generates // "SELECT ... FROM app_user WHERE username = ?" with no SQL written by hand. Optional<User> findByUsername(String username);}package com.programinds.notesapp.repository;
import com.programinds.notesapp.model.Note;import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;import java.util.Optional;
public interface NoteRepository extends JpaRepository<Note, Long> { // Traverses the owner relationship from Step 1: generates a query joining // note to app_user and filtering by the owner's username. List<Note> findByOwnerUsername(String username);
// Used by NoteService to load one note while confirming who owns it, in a // single query rather than a separate ownership check after the fact. Optional<Note> findByIdAndOwnerUsername(Long id, String username);}Step 3: Implement UserDetailsService
Spring Security never queries your database directly — it delegates to a `UserDetailsService`, an interface with exactly one method, `loadUserByUsername`. `CustomUserDetailsService` bridges the two worlds: it fetches your own `User` entity via `UserRepository`, then wraps it in Spring Security's own `org.springframework.security.core.userdetails.User`, which is what the authentication mechanism actually compares credentials against. Note that `.password(user.getPassword())` passes along the already-hashed value from the database — Spring Security compares an incoming raw password by hashing it and comparing hashes, never by decrypting anything, because BCrypt hashes cannot be decrypted at all.
package com.programinds.notesapp.security;
import com.programinds.notesapp.repository.UserRepository;import org.springframework.security.core.userdetails.*;import org.springframework.stereotype.Service;
// The one bridge between Spring Security's authentication machinery and this// app's own User table. Spring Security calls loadUserByUsername on every// login attempt; everything after that is handled by the framework.@Servicepublic class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; }
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { com.programinds.notesapp.model.User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("No user with username: " + username));
// Spring Security's own User type, built from this app's data. The // password passed in is already a BCrypt hash (set at registration in // Step 6) — Spring Security hashes the incoming attempt and compares // hashes, it never decrypts this value. return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles("USER") .build(); }}Step 4: Configure the SecurityFilterChain
The `SecurityFilterChain` bean is the single place every access rule in this app is declared. `.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()` is listed before `.anyRequest().authenticated()` deliberately — Spring Security evaluates these rules in order, and a more specific rule must come before the catch-all that would otherwise block it. `httpBasic()` enables HTTP Basic authentication, meaning every protected request must carry an `Authorization: Basic <base64(username:password)>` header.
package com.programinds.notesapp.security;
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.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.SecurityFilterChain;
@Configurationpublic class SecurityConfig {
// BCryptPasswordEncoder is a one-way hash: verifying a login means re-hashing // the submitted password and comparing hashes, never decrypting the stored // value. Declared as a bean so both registration and login share one instance. @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // CSRF protection defends browser form submissions that ride on a // logged-in session cookie. This API is stateless and tested with // curl/Postman sending credentials on every request, so it is disabled // here — a server-rendered form-based app would need to keep it on. .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth // Order matters: this specific rule must be listed before the // catch-all below, or registration itself would require being // already logged in — an impossible chicken-and-egg situation. .requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll() .anyRequest().authenticated() // Every other endpoint requires valid credentials ) .httpBasic(Customizer.withDefaults()); // Credentials sent via the Authorization header on every request
return http.build(); }}Disabling CSRF protection is safe here specifically because this API never relies on a session cookie for authentication — every request must carry its own credentials. A browser-based app that authenticates with a session cookie must keep CSRF protection enabled, or a malicious page could ride an authenticated user's cookie to perform actions on their behalf.
Step 5: Scope Notes to the Authenticated User
This is the step that turns "a notes app with a login screen" into "a notes app where users cannot read each other's notes." `currentUsername()` reads `SecurityContextHolder.getContext().getAuthentication()`, which Spring Security populates on every authenticated request — `.getName()` returns exactly the username the request was authenticated as, not a value the client can spoof by passing a different id in the URL or body. Every method below calls it, and every query is filtered by it.
package com.programinds.notesapp.service;
import com.programinds.notesapp.model.Note;import com.programinds.notesapp.model.User;import com.programinds.notesapp.repository.NoteRepository;import org.springframework.security.core.Authentication;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.stereotype.Service;
import java.util.List;import java.util.Optional;
@Servicepublic class NoteService {
private final NoteRepository noteRepository;
public NoteService(NoteRepository noteRepository) { this.noteRepository = noteRepository; }
public List<Note> getNotesForCurrentUser() { // Only ever returns notes owned by whoever this request authenticated as // — never a client-supplied id, which a caller could tamper with. return noteRepository.findByOwnerUsername(currentUsername()); }
public Optional<Note> getNoteForCurrentUser(Long id) { // A single query filtered by both id AND owner username: a note that // exists but belongs to someone else comes back empty here, exactly the // same as a note that does not exist at all. return noteRepository.findByIdAndOwnerUsername(id, currentUsername()); }
public Note createNote(String content, User owner) { Note note = new Note(); note.setContent(content); note.setOwner(owner); // Owner is set from the authenticated principal in the controller, never from the request body return noteRepository.save(note); }
// Reads the identity Spring Security's filter chain already established for // this request. There is no way for a client to make this return someone // else's username short of stealing that user's own credentials. private String currentUsername() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication.getName(); }}Step 6: Build the Controllers
`AuthController` is the one endpoint `SecurityConfig` marked as `permitAll()` — it hashes the incoming password with the `PasswordEncoder` bean before ever calling `save()`, so the plaintext value passed in the request never touches the database. `NoteController` never trusts an owner id from the client at all: `create()` reads `authentication.getName()` to look up the real `User` to attach as owner, and every other method delegates straight to the already-scoped `NoteService` methods from Step 5.
package com.programinds.notesapp.controller;
import com.programinds.notesapp.dto.RegisterRequest;import com.programinds.notesapp.model.User;import com.programinds.notesapp.repository.UserRepository;import jakarta.validation.Valid;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/auth")public class AuthController {
private final UserRepository userRepository; private final PasswordEncoder passwordEncoder;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; }
@PostMapping("/register") // Reachable without auth — this is the request SecurityConfig's permitAll() covers public ResponseEntity<String> register(@Valid @RequestBody RegisterRequest request) { User user = new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); // Hash before saving; the raw password is discarded userRepository.save(user); return ResponseEntity.status(HttpStatus.CREATED).body("User registered"); }}package com.programinds.notesapp.controller;
import com.programinds.notesapp.dto.NoteRequest;import com.programinds.notesapp.model.Note;import com.programinds.notesapp.repository.UserRepository;import com.programinds.notesapp.service.NoteService;import jakarta.validation.Valid;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.security.core.Authentication;import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController@RequestMapping("/api/notes") // Every method here requires authentication, per SecurityConfig's anyRequest().authenticated()public class NoteController {
private final NoteService noteService; private final UserRepository userRepository;
public NoteController(NoteService noteService, UserRepository userRepository) { this.noteService = noteService; this.userRepository = userRepository; }
@GetMapping public List<Note> getMyNotes() { return noteService.getNotesForCurrentUser(); // Already scoped by Step 5 — no id filtering needed here }
@GetMapping("/{id}") public ResponseEntity<Note> getOne(@PathVariable Long id) { return noteService.getNoteForCurrentUser(id) .map(ResponseEntity::ok) // Deliberately the same 404 whether the note doesn't exist or belongs // to someone else — this endpoint never reveals that another user's // note with this id exists at all. .orElse(ResponseEntity.notFound().build()); }
@PostMapping public ResponseEntity<Note> create(@Valid @RequestBody NoteRequest request, Authentication authentication) { // authentication.getName() is the identity Spring Security already // verified for this request — the owner is never taken from the body. var owner = userRepository.findByUsername(authentication.getName()).orElseThrow(); Note saved = noteService.createNote(request.getContent(), owner); return ResponseEntity.status(HttpStatus.CREATED).body(saved); }}Complete Code
Here are the remaining DTOs and the application entry point, completing the project. Run it with `./mvnw spring-boot:run`; every endpoint under `/api/notes` requires HTTP Basic credentials for a user created via `POST /api/auth/register`.
package com.programinds.notesapp.dto;
import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;
public class RegisterRequest {
@NotBlank private String username;
@NotBlank @Size(min = 8, message = "Password must be at least 8 characters") // Enforced before the password is ever hashed private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }}package com.programinds.notesapp.dto;
import jakarta.validation.constraints.NotBlank;
public class NoteRequest {
@NotBlank(message = "Content must not be blank") private String content;
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }}package com.programinds.notesapp;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class NotesAppApplication { public static void main(String[] args) { SpringApplication.run(NotesAppApplication.class, args); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add `@PreAuthorize("#id == authentication.principal.username")`-style method security as an alternative to the service-layer filtering used here, once `@EnableMethodSecurity` is configured.
- Add a `PUT /api/notes/{id}` and `DELETE /api/notes/{id}` endpoint, both routed through `NoteService.getNoteForCurrentUser()` first so ownership is checked before any mutation.
- Replace HTTP Basic with stateless JWT authentication, issuing a token from `POST /api/auth/login` and validating it in a custom `OncePerRequestFilter`.
- Add a `roles` field to `User` and an `ADMIN` role permitted to list every user's notes via a separate, explicitly authorized endpoint.
- Rate-limit `POST /api/auth/register` to blunt automated account-creation abuse.
Summary
You built a working authenticated API where Spring Security's filter chain rejects unauthenticated requests before they ever reach a controller, `CustomUserDetailsService` bridges Spring Security to your own `User` table, and `NoteService` enforces per-user data ownership by trusting only `SecurityContextHolder`'s authenticated principal — never an id supplied by the client. That last pattern, filtering every query by the authenticated identity rather than by anything the request itself claims, is the core idea behind per-user data scoping in any real Spring Boot application.