Authentication vs Authorization

Two different questions the security layer must answer — who is this, and what are they allowed to do — and why conflating them is the source of most access-control bugs

← Back to Index

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

StepQuestion askedFailure statusFailure 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
AspectAuthenticationAuthorization
QuestionWho are you?What can you do?
VerifiesIdentityPermissions
Failure code401 Unauthorized403 Forbidden
MechanismsPassword, TOTP, WebAuthn/biometricsRoles, permissions, ACLs, attribute policies
OccursFirst, once per session/tokenAfter 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)));
}
FactorExamplesProsCons
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
See Password Hashing for how PasswordEncoder actually verifies the knowledge factor

This 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);
    }
}
Blanket FetchType.EAGER on role/permission collections reintroduces N+1

Loading 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;
        };
    }
}
ModelBest forImplementation complexityFlexibility
RBACMost applications with clear role hierarchiesLowMedium
Permission-basedFine-grained control across many resource typesMediumHigh
ABACContext-aware, multi-factor policies (compliance, finance)HighVery 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;
    }
}
The self-invocation trap applies to @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 AuthenticationManager and PasswordEncoder handle credential comparison — never write your own String.equals() password check
  • Return 401 for identity failures and 403 for permission failures — never conflate the two in application code
  • Keep role/permission collections LAZY; load them explicitly with JOIN FETCH at login time rather than defaulting to EAGER
  • 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 via this.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

🎓 Junior level

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.

🔥 Senior level

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.