DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 34: Spring Security basics — a filter chain, BCrypt users, and why 401 403

Through Day 33 every endpoint in OrderHub was wide open — anyone who could reach the service could place an order, browse every order, or hit the actuator. Day 34 puts Spring Security in front of the app: each request first authenticates (who are you? — HTTP Basic against BCrypt-hashed users), then authorizes (are you allowed? — reads need ROLE_USER, writes need ROLE_ADMIN, health and docs are public). I built a page that sends the same request as three identities and shows exactly where each one stops, plus a plaintext-vs-BCrypt panel. Here's the shape of it.

The security filter chain is the heart of it

Spring Security works by inserting a chain of servlet filters in front of your app; the SecurityFilterChain bean declares what that chain does. But there's a trap: just adding spring-boot-starter-security to the classpath flips the default to "deny all, HTTP Basic, random generated password" — which would instantly 401 every existing test. So the whole feature ships behind a flag, orderhub.security.enabled, defaulting to false, with two chains selected by @ConditionalOnProperty:

@Bean
@ConditionalOnProperty(prefix="orderhub.security", name="enabled", havingValue="true")
SecurityFilterChain securedFilterChain(HttpSecurity http) throws Exception {
  http.csrf(c -> c.disable())
      .authorizeHttpRequests(/* role rules */)
      .httpBasic(Customizer.withDefaults());
  return http.build();
}

@Bean   // default -> permit everything, app unchanged (Day 33 behaviour)
@ConditionalOnProperty(prefix="orderhub.security", name="enabled",
                       havingValue="false", matchIfMissing=true)
SecurityFilterChain openFilterChain(HttpSecurity http) throws Exception {
  http.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.anyRequest().permitAll());
  return http.build();
}
Enter fullscreen mode Exit fullscreen mode

With the flag off, the open chain permits all and every prior test stays green — the starter is present but inert. CSRF is disabled in both because this is a stateless per-request API (Basic, later JWT), not a cookie-session browser app.

Authentication vs authorization — two different questions

Authentication asks who are you? Today that's HTTP Basic: the client sends Authorization: Basic base64(user:pass), and Spring verifies it against a UserDetailsService. For a first cut I use an in-memory store with two users — a plain user (ROLE_USER) and an admin (ROLE_ADMIN + ROLE_USER, so admin can do everything a user can plus writes).

Authorization asks are you allowed? — checking the authenticated identity's roles against a per-endpoint rule, evaluated top-to-bottom, first match wins:

http.authorizeHttpRequests(auth -> auth
  .requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
  .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
  .requestMatchers(HttpMethod.GET,  "/api/**").hasRole("USER")   // reads
  .requestMatchers(HttpMethod.POST, "/api/**").hasRole("ADMIN")  // writes
  .anyRequest().authenticated());
Enter fullscreen mode Exit fullscreen mode

Why 401 and 403 are different on purpose

This is the concept worth internalising. The two failures come from the two different steps, and they mean different things:

  • 401 Unauthorized — no identity at all. Spring can't establish who you are, so the entry point challenges with a WWW-Authenticate header. It's a prompt to log in, not a permanent refusal — add valid credentials and you advance.
  • 403 Forbidden — a valid identity that lacks the required role. The credentials matched, authentication passed, but a user posting to an admin-only write is refused. Retrying with the same identity never helps.

In the demo, a write makes all three visible: anonymous stops at authenticate (401), a user passes authentication but stops at authorize (403), and only admin reaches the handler (201). Collapsing both codes into "something went wrong" is a classic UX bug — it either traps an authorized user in a login loop or hides that they simply lack the role.

BCrypt — passwords hashed, never in the clear

The password encoder turns "user-pw" into something safe to store. BCryptPasswordEncoder is one-way, per-user-salted, and deliberately slow — encode() produces a 60-char string holding the algorithm, a cost factor, the salt, and the hash, and matches(raw, stored) re-hashes the presented password with that salt and compares; the original is never recoverable:

@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
// stored:  admin -> $2a$10$Qk7....v2Lb1   (never the plaintext)
Enter fullscreen mode Exit fullscreen mode

This matters because databases leak. With plaintext (or reversible "encryption") a single dump hands over every password — and since people reuse passwords, their email and bank too. The salt means identical passwords hash differently, defeating rainbow tables; the tunable cost lets you make guessing slower as hardware speeds up. BCrypt turns a leak from "instant catastrophe" into "expensive per-user brute force." Day 34 is the foundation; Day 35 swaps per-request Basic credentials for a stateless signed JWT.

Send the same request as three identities and watch the chain:
https://dev48v.infy.uk/orderhub.php

Repo: https://github.com/dev48v/order-hub-from-zero

Top comments (0)