Beyond roles: attribute and domain-object authorization
When hasRole stops being enough, and how to express "only their own orders".
Open this lesson in the learning hubKey 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.
@PreAuthorizechecks before the method runs, which is cheaper and safer.@PostAuthorizechecks 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
AuthorizationManageror a policy service rather than a growing SpEL string. Expressions are not testable or debuggable at any real complexity. - Remember
hasRoleprependsROLE_andhasAuthoritydoes 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.