Beyond roles: attribute and domain-object authorization

Spring Security · lesson 30 of 31 · 6 min read

When hasRole stops being enough, and how to express "only their own orders".

Open this lesson in the learning hub

Key points

  • Role checks answer "what kind of user is this". They cannot answer "may this user see this record", which is where most real authorization bugs live.
  • The failure is silent and serious: an endpoint correctly requires ROLE_USER, and any user can then fetch any other user order by changing the id. That is a broken object-level authorization flaw, consistently near the top of the OWASP list.
  • Spring Security expressions can reference the arguments and the return value, so a rule can depend on the object itself rather than only on the principal.
  • @PreAuthorize checks before the method runs, which is cheaper and safer. @PostAuthorize checks the returned object - necessary when ownership is only knowable after loading, but it means the work is already done.
  • For anything non-trivial, put the decision in an AuthorizationManager or a policy service rather than a growing SpEL string. Expressions are not testable or debuggable at any real complexity.
  • Remember hasRole prepends ROLE_ and hasAuthority does not. Mixing them is behind a great many silent 403s that look like configuration problems.

Example

@Service
public class OrderService {

    // Role only - any authenticated user can read ANY order by changing the id.
    @PreAuthorize("hasRole('USER')")
    public Order getBroken(Long id) { return repo.findById(id).orElseThrow(); }

    // Ownership is only knowable after loading, so check the RETURN value.
    @PostAuthorize("returnObject.customerId == authentication.name or hasRole('ADMIN')")
    public Order get(Long id) { return repo.findById(id).orElseThrow(); }

    // Better where possible: never load what may not be returned.
    @PreAuthorize("@orderPolicy.canRead(#id, authentication)")
    public Order getChecked(Long id) { return repo.findById(id).orElseThrow(); }

    // Filter a collection down to what the caller may see.
    @PostFilter("filterObject.customerId == authentication.name")
    public List<Order> listAll() { return repo.findAll(); }
    // NOTE: this loads everything then discards - fine for tens of rows,
    // wrong for thousands. Push the predicate into the query instead.
}

// A testable policy, rather than an ever-growing SpEL string.
@Component("orderPolicy")
class OrderPolicy {

    boolean canRead(Long orderId, Authentication auth) {
        if (auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
            return true;
        }
        return orders.existsByIdAndCustomerId(orderId, auth.getName());
    }
}

/*
 *   hasRole('ADMIN')       looks for authority  ROLE_ADMIN   (prefix added)
 *   hasAuthority('ADMIN')  looks for authority  ADMIN        (taken literally)
 *
 * Mixing these is the most common cause of a 403 that looks inexplicable.
 */

A role check cannot say whose record this is - without an ownership check, any user can read any row by changing the id.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Spring Security course, and every lesson in it is listed on the Spring Security contents page.