What is Authentication vs Authorization — and Why Does the Distinction Matter?
Authentication (AuthN) answers "who is this?" — it verifies an identity against a set of credentials. Authorization (AuthZ) answers "what is this identity allowed to do?" — it checks a permission against a resource, and only runs after authentication has already established who is asking. They are two separate concerns solved by two separate mechanisms, and the moment an application tangles them together in the same block of code, one of two things happens: either authorization checks get skipped for some code paths, or a hand-rolled comparison replaces a hardened framework mechanism that already handles timing attacks, password hashing, and session fixation correctly.
The two failure modes have different HTTP status codes for exactly this
reason: 401 Unauthorized means the identity could not be
established (authentication failed), and 403 Forbidden means
the identity is known but lacks permission for this specific action
(authorization failed). Returning the wrong one is not cosmetic — it
changes what a client, a monitoring dashboard, or an attacker can infer
about your system.
// BEFORE — identity check and permission check tangled together in application code
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
String username = req.getParameter("username");
String password = req.getParameter("password");
User user = userDao.findByUsername(username);
if (user == null || !user.getPassword().equals(password)) {
resp.setStatus(401); // hand-rolled, plaintext comparison, no timing-attack protection
return;
}
if (!user.getRole().equals("ADMIN")) {
resp.setStatus(403); // bolted onto the same method as identity verification
return;
}
// ... business logic now buried under two unrelated security concerns
}
// AFTER — authentication is a filter-chain concern; authorization is declarative
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/admin/users/{id}")
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
// By the time this line runs, Spring Security's filter chain has already
// verified identity, and @PreAuthorize has already verified permission.
// Neither concern is application code's responsibility here.
}
The Request Lifecycle
| Step | Question asked | Failure status | Failure meaning |
|---|---|---|---|
| 1. Authentication | Who is this? | 401 Unauthorized |
No valid identity could be established — not logged in, expired token, bad credentials |
| 2. Authorization | Can this identity do X? | 403 Forbidden |
Identity is known, but lacks the required role/permission for this specific action |
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | Who are you? | What can you do? |
| Verifies | Identity | Permissions |
| Failure code | 401 Unauthorized | 403 Forbidden |
| Mechanisms | Password, TOTP, WebAuthn/biometrics | Roles, permissions, ACLs, attribute policies |
| Occurs | First, once per session/token | After authentication, once per protected action |
Authentication Methods — The Three Factors
Every authentication mechanism ultimately reduces to one of three factors: something you know, something you have, or something you are. Multi-factor authentication (MFA) means combining at least two different factor types — a password plus a TOTP code is two-factor; a password plus a security question is not, because both are knowledge-based and both are stolen the same way (phishing).
1. Knowledge-Based (Something You Know) — Password Authentication
// DTOs as records — no setters, no mutable state to leak a password reference around
public record LoginRequest(String username, String password) {}
public record AuthResponse(String token) {}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
// Constructor injection — single constructor, no @Autowired needed (Spring 4.3+)
public AuthController(AuthenticationManager authManager, JwtService jwtService) {
this.authManager = authManager;
this.jwtService = jwtService;
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
// AuthenticationManager delegates to the PasswordEncoder — never compare
// raw strings yourself, and never log the password, even on failure.
String token = jwtService.generateToken(auth);
return ResponseEntity.ok(new AuthResponse(token));
}
}
// Custom UserDetailsService — the bridge between your User entity and Spring Security
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(), // already hashed — never store or compare raw
user.isEnabled(),
true, true, true, // accountNonExpired, credentialsNonExpired, accountNonLocked
toAuthorities(user.getRoles())
);
}
}
2. Possession-Based (Something You Have) — TOTP Second Factor
@Service
public class TwoFactorAuthService {
private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
// Generate a per-user secret, shown to the user as a QR code once
public String generateSecret() {
return gAuth.createCredentials().getKey(); // store encrypted, never in plaintext logs
}
// Verify the 6-digit code from the user's authenticator app
public boolean verifyCode(String secret, int code) {
return gAuth.authorize(secret, code);
}
}
// The login flow branches once the password step succeeds
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
User user = userService.findByUsername(request.username());
if (user.isTwoFactorEnabled()) {
if (request.totpCode() == null) {
return ResponseEntity.ok(new TwoFactorRequiredResponse());
}
if (!twoFactorService.verifyCode(user.getTotpSecret(), request.totpCode())) {
throw new BadCredentialsException("Invalid 2FA code");
}
}
return ResponseEntity.ok(new AuthResponse(jwtService.generateToken(auth)));
}
3. Inherence-Based (Something You Are) — WebAuthn / Biometrics
// Biometrics never leave the device. The server verifies a cryptographic
// signature produced locally by the authenticator (fingerprint sensor, Face ID),
// it never receives a fingerprint image or template.
@PostMapping("/biometric-auth")
public ResponseEntity<AuthResponse> biometricAuth(@RequestBody BiometricAuthRequest request) {
boolean valid = webAuthnService.verifyAssertion(
request.userId(), request.authenticatorData(),
request.signature(), request.clientDataJson()
);
if (!valid) {
throw new BadCredentialsException("Biometric verification failed");
}
User user = userService.findById(request.userId());
return ResponseEntity.ok(new AuthResponse(jwtService.generateToken(user)));
}
| Factor | Examples | Pros | Cons |
|---|---|---|---|
| Knowledge | Password, PIN, security question | No hardware required, cheap to implement | Phishable, reusable if stolen, often reused across sites by users |
| Possession | Phone (SMS/TOTP), hardware key, smart card | Hard to steal remotely without physical access | Can be lost; SMS is vulnerable to SIM-swapping attacks |
| Inherence | Fingerprint, Face ID, WebAuthn platform authenticator | Can't be forgotten, nothing to phish (signature stays on device) | Cannot be rotated if the underlying biometric database were ever compromised |
PasswordEncoder actually verifies the knowledge factorThis page treats password verification as a black box handled by
AuthenticationManager. The mechanics of bcrypt/Argon2,
salting, and work factors — and why comparing hashes with
String.equals() is a timing-attack vulnerability — are
covered on the dedicated Password Hashing page.
Authorization Models — RBAC, Permission-Based, and ABAC
Authorization models differ in what they check a permission against. RBAC checks a role. Permission-based checks a granular authority. ABAC checks an arbitrary combination of attributes about the subject, the resource, and the environment. Each is a legitimate default for a different level of complexity — reaching for ABAC when RBAC would have sufficed adds maintenance cost with no corresponding benefit.
1. Role-Based Access Control (RBAC)
// Simple RBAC: users have roles, roles have permissions
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToMany(fetch = FetchType.LAZY) // LAZY by default — see the note below before reaching for EAGER
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}
@Entity
public class Role {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name; // ROLE_ADMIN, ROLE_MANAGER, ROLE_USER
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
}
// Declarative role checks — Spring AOP evaluates these before the method body runs
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
FetchType.EAGER on role/permission collections reintroduces N+1Loading a Set<Role> eagerly on every
User means every query that touches a user — not just
login — joins two extra tables it may never need. This is the exact
anti-pattern described in
Entity Relationships:
EAGER doesn't prevent N+1, it just makes it unconditional. Keep the
association LAZY and let CustomUserDetailsService load
roles explicitly with a JOIN FETCH query at login time,
when you actually need them to build the UserDetails
authority list.
2. Permission-Based Access Control
// Fine-grained authorities instead of coarse roles
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceService invoiceService;
public InvoiceController(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@PreAuthorize("hasAuthority('INVOICE_READ')")
@GetMapping
public List<Invoice> getInvoices() {
return invoiceService.findAll();
}
// Combine a coarse authority check with an ownership check via SpEL
@PreAuthorize("hasAuthority('INVOICE_DELETE') and #invoice.owner == authentication.name")
@DeleteMapping("/{id}")
public void deleteInvoice(@PathVariable Long id, Invoice invoice) {
invoiceService.delete(id);
}
}
// Custom permission evaluator for logic too complex for a single SpEL expression
@Component
public class InvoicePermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
if (!(target instanceof Invoice invoice)) return false;
String username = auth.getName();
return switch ((String) permission) {
case "READ" -> invoice.getOwner().equals(username)
|| invoice.getSharedWith().contains(username);
case "WRITE", "DELETE" -> invoice.getOwner().equals(username);
default -> false;
};
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
return false; // load-by-id variant, implement only if you need it
}
}
3. Attribute-Based Access Control (ABAC)
// ABAC evaluates a policy against subject, resource, action, and environment attributes
// simultaneously — used when role and permission alone can't express the rule.
@Service
public class InvoiceAccessPolicy {
private final Set<String> trustedIps;
public InvoiceAccessPolicy(@Value("${security.trusted-ips}") Set<String> trustedIps) {
this.trustedIps = trustedIps;
}
// Policy: finance staff can read invoices from their own department;
// writing also requires business hours and a trusted network location.
public boolean canAccess(User user, Invoice invoice, String action, AccessContext context) {
boolean sameDepartment = user.getDepartment().equals(invoice.getDepartment());
boolean hasClearance = user.getClearanceLevel() >= invoice.getClassificationLevel();
boolean businessHours = isWithinBusinessHours(context.time());
boolean trustedNetwork = trustedIps.contains(context.ipAddress());
return switch (action) {
case "READ" -> sameDepartment && hasClearance;
case "WRITE" -> sameDepartment && hasClearance && businessHours && trustedNetwork;
case "DELETE" -> invoice.getOwner().equals(user.getId()) && user.getClearanceLevel() >= 4;
default -> false;
};
}
}
| Model | Best for | Implementation complexity | Flexibility |
|---|---|---|---|
| RBAC | Most applications with clear role hierarchies | Low | Medium |
| Permission-based | Fine-grained control across many resource types | Medium | High |
| ABAC | Context-aware, multi-factor policies (compliance, finance) | High | Very high |
Wiring It Together — Spring Security Configuration
The filter chain is where authentication happens (the JWT filter, or
the username/password filter). authorizeHttpRequests and
@PreAuthorize are where authorization happens. Keeping them
visually separate in the configuration mirrors the conceptual
separation — resist the temptation to smuggle authorization logic into
a custom authentication filter.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize / @PostAuthorize / @PostFilter
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
private final ObjectMapper objectMapper;
// Constructor injection — single constructor, no @Autowired needed (Spring 4.3+)
public SecurityConfig(JwtAuthenticationFilter jwtFilter, ObjectMapper objectMapper) {
this.jwtFilter = jwtFilter;
this.objectMapper = objectMapper;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // safe for a stateless, token-based API — see CSRF notes on the CORS page
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAuthority("DELETE_PRIVILEGE")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// ProblemDetail (RFC 9457) instead of a hand-rolled body — consistent with every
// other error response in the application, see the REST error-handling topic.
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "Authentication is required to access this resource");
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
objectMapper.writeValue(res.getWriter(), problem);
})
.accessDeniedHandler((req, res, e) -> {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, "You do not have permission to perform this action");
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
objectMapper.writeValue(res.getWriter(), problem);
})
)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // see Password Hashing for why bcrypt and not MD5/SHA-256
}
}
Common Patterns — Role Hierarchy and Resource Ownership
Role Hierarchy
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("MANAGER")
.role("MANAGER").implies("USER")
.build();
}
// ADMIN now automatically satisfies hasRole('MANAGER') and hasRole('USER') checks
// without every @PreAuthorize expression having to spell out hasAnyRole(...)
Resource Ownership — Verify at the Layer Where the Data Lives
// Option A: express ownership directly in the @PreAuthorize expression
@GetMapping("/api/users/{userId}/orders")
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public List<Order> getUserOrders(@PathVariable Long userId) {
return orderService.findByUserId(userId);
}
// Option B: verify ownership in the service layer — necessary when the check
// depends on data that must be loaded first, or when the service is also called
// from non-HTTP entry points (batch jobs, message listeners) that never go through
// a controller and therefore never go through @PreAuthorize.
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order getOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
String currentUser = SecurityContextHolder.getContext().getAuthentication().getName();
if (!order.getOwner().equals(currentUser)) {
throw new AccessDeniedException("Not your order");
}
return order;
}
}
@PreAuthorize too@PreAuthorize is implemented as a Spring AOP proxy, the
same mechanism behind @Transactional. Calling an
annotated method via this.someMethod() from within the
same bean bypasses the proxy, and the authorization check is silently
skipped — no exception, no warning, the method just runs
unauthorized. The fix is identical to the one covered for
transactions: inject the bean into itself and call through that
reference, or extract the method into a separate Spring-managed
bean. See Transactions —
the proxy bypass trap for the full mechanism.
Best Practices and Common Pitfalls
✅ Do
- Let
AuthenticationManagerandPasswordEncoderhandle credential comparison — never write your ownString.equals()password check - Return
401for identity failures and403for permission failures — never conflate the two in application code - Keep role/permission collections
LAZY; load them explicitly withJOIN FETCHat login time rather than defaulting toEAGER - Verify ownership in the service layer when a method is reachable from more than one entry point (HTTP, batch job, message listener)
- Use a role hierarchy to avoid repeating
hasAnyRole(...)across every endpoint - Offer MFA (a true second factor type, not two knowledge checks) for sensitive operations
❌ Don't
- Don't rely on client-side or UI-only authorization — hidden buttons are not access control; enforce every check on the server
- Don't call an
@PreAuthorize-annotated method viathis.method()from the same bean — the proxy is bypassed and the check silently never runs - Don't reveal in an error message whether the username or the password was wrong — that turns a login form into a username enumeration tool
- Don't hardcode credentials or secrets in source — use environment variables or a secrets manager
- Don't reach for ABAC by default — it's the right tool for context-dependent compliance policies, not for a plain admin/user split that RBAC already solves
Interview Questions
Q: What is the difference between authentication and authorization, and which HTTP status code corresponds to each failure?
Authentication verifies identity — who is making the request — and its
failure returns 401 Unauthorized. Authorization verifies
permission — what that identity is allowed to do — and its failure
returns 403 Forbidden. Authentication always happens
first; authorization only makes sense once an identity is already
established.
Q: What are the three factors of authentication?
Something you know (password, PIN), something you have (phone, hardware
token), and something you are (fingerprint, Face ID). True multi-factor
authentication requires at least two different factor types — a
password plus a security question is still single-factor, since both
are knowledge-based.
Q: What is RBAC and why is it usually the right default for authorization?
Role-Based Access Control grants permissions through roles (ADMIN,
MANAGER, USER) rather than checking individual attributes per request.
It's the right default because most applications have a small, stable
set of roles with clear hierarchies, and RBAC keeps authorization
logic declarative and easy to audit — reach for permission-based or
ABAC only when RBAC genuinely can't express the required rule.
Q: A method is annotated @PreAuthorize("hasRole('ADMIN')"), but a test proves a non-admin user can still execute it. The annotation is spelled correctly. What's happening?
The method is very likely being called via this.method()
from another method in the same Spring bean.
@PreAuthorize is implemented as a Spring AOP proxy around
the bean; a direct this call never goes through that proxy,
so the interceptor that evaluates the SpEL expression never runs. The
method executes as if unprotected. This is the same class of bug as
a @Transactional method silently not starting a new
transaction on self-invocation. Fix: inject the bean into itself and
call through that reference, or move the method to a separate
Spring-managed bean so the call crosses a real proxy boundary.
Q: Why is loading a User's roles with FetchType.EAGER a scaling risk, even though the role list is small?
The risk isn't the size of the role set — it's that EAGER forces the
join on every single query that touches User, including
ones that have nothing to do with authorization: profile lookups,
admin listings, batch exports. Every one of those queries now pays for
two extra joins it doesn't need, and because EAGER is unconditional,
there's no query-specific way to opt out short of switching back to
LAZY. The correct pattern is LAZY by default, with the roles fetched
explicitly (via JOIN FETCH or an @EntityGraph)
at the one place that actually needs them: building the
UserDetails object during authentication.
Q: You need to enforce that a user can only cancel their own orders. Would you put that check in @PreAuthorize on the controller, in the service layer, or both — and why?
Both, for different reasons. @PreAuthorize on the
controller gives you a fast, declarative rejection at the HTTP boundary
and keeps the authorization rule visible next to the endpoint
definition. But if OrderService.cancel() is also called
from a scheduled job, an admin tool, or a message listener that never
goes through that controller, the SpEL check is never evaluated for
those callers, and the ownership rule is silently unenforced there. The
service-layer check using SecurityContextHolder is the one
that actually guarantees the invariant regardless of entry point;
the controller-level @PreAuthorize is a defense-in-depth
optimization on top of it, not a substitute for it.