Dev.to Security 🔐 Cybersecurity 👁 1 📖 3 min read

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 fi

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();
}

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());

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)

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

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.