Roles, authorities and method security
Authentication answers who are you; authorisation answers what may you do. A Dakiya rider may update a parcel's status; only an admin may delete a hub; a partner may read their own shipments but not others'. Spring Security expresses this with roles and authorities, enforced either on URL patterns or on individual methods. This lesson is how, verified against a running Dakiya.
Roles and authorities
Spring Security attaches authorities to an authenticated user — strings representing what they are
allowed. A role is just an authority with a conventional ROLE_ prefix. When you build a user with
.roles("STAFF"), Spring stores the authority ROLE_STAFF:
UserDetails staff = User.withUsername("staff").password(...).roles("STAFF").build(); // authority ROLE_STAFF
UserDetails admin = User.withUsername("admin").password(...).roles("ADMIN").build(); // authority ROLE_ADMIN
The ROLE_ prefix is a convention with a catch that trips everyone: hasRole("ADMIN") checks for the
authority ROLE_ADMIN — it adds the prefix for you. So you write roles("ADMIN") (no prefix) and
hasRole("ADMIN") (no prefix), and Spring handles the ROLE_ in between. If you use the lower-level
hasAuthority(...), you must write the full authority including any prefix (hasAuthority("ROLE_ADMIN")).
Roles are the coarse "what kind of user" (STAFF, ADMIN); authorities can be finer-grained permissions
(parcel:write) if you need them.
URL-based authorisation
The simplest authorisation is per URL pattern, in the filter chain (from the basics lesson):
.authorizeHttpRequests(a -> a
.requestMatchers("/ping").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/hubs/**").hasRole("ADMIN") // only admins delete hubs
.requestMatchers("/api/**").authenticated() // any logged-in user otherwise
.anyRequest().permitAll())
Rules are matched top to bottom, first match wins — so the specific DELETE /api/hubs/** rule must come
before the general /api/** rule, or the general one would catch it first. hasRole("ADMIN") requires
the ROLE_ADMIN authority; hasAnyRole("STAFF", "ADMIN") accepts either; authenticated() just requires a
login. URL-based rules are good for broad, path-level policy ("admins only under /api/admin").
Method-level authorisation with @PreAuthorize
For finer control — rules on individual service or controller methods, closer to the logic — enable method security and annotate methods:
@Configuration
@EnableMethodSecurity // turn on @PreAuthorize
public class SecurityConfig { ... }
@Service
public class HubService {
@PreAuthorize("hasRole('ADMIN')") // only admins may run this method
public void deleteHub(Long id) { ... }
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public void updateParcelStatus(Long id, Status status) { ... }
}
@PreAuthorize runs its expression before the method; if it fails, the method never executes and Spring
returns 403 Forbidden. @EnableMethodSecurity (on a config class) switches this on. Method security is
powerful because the check lives with the logic it protects — so the rule is enforced no matter which
controller or path calls the method, and you can write expressions referencing the arguments and the
principal (@PreAuthorize("#userId == authentication.name") for "only your own records"). Prefer method
security for business-rule authorisation; use URL rules for broad path policy. The two combine.
401 versus 403: a distinction that matters
Authorisation failures produce a specific status, and confusing it with authentication's is a common error:
- 401 Unauthorized means not authenticated — "I do not know who you are" (no or invalid credentials). Verified earlier: an anonymous request to a protected endpoint returned 401.
- 403 Forbidden means authenticated but not allowed — "I know who you are, and you may not do this" (a logged-in STAFF user hitting an ADMIN-only endpoint).
So a missing token is 401; a valid token without the required role is 403. Getting these right is part of a correct API contract — a client handles "log in again" (401) differently from "you lack permission" (403). Spring Security returns them appropriately when you use its mechanisms; your custom error handling (the REST module) should preserve the distinction.
Object-level authorisation: "only your own"
Role checks answer "may this kind of user do this?"; they cannot answer "may this user touch this specific record?" — a partner reading their parcels but not others'. As in the Django course, the primary tool is scoping the query to the user, not a role check:
// return only the caller's parcels — forbidden rows are never in the result
List<Parcel> myParcels(String username) {
return repository.findByOwnerUsername(username);
}
Because the forbidden rows are never fetched, there is nothing to leak — the safest object-level rule. For a
single-object fetch, check ownership after loading (or fold it into the query) and return 404 for
records the caller should not even know exist. @PreAuthorize can also express object rules
(@PostAuthorize("returnObject.owner == authentication.name")), but query-scoping is usually simpler and
safer. The layered model, as before: authenticated → has the right role → may see this specific object —
and sensitive data needs all three, with object-level scoping doing the last.
Check your work
Roles and authorities. A user has authorities; a role is an authority with a ROLE_ prefix. .roles("ADMIN")
stores ROLE_ADMIN; hasRole("ADMIN") checks ROLE_ADMIN (adds the prefix); hasAuthority(...) needs the
full string.
URL-based. authorizeHttpRequests with permitAll/authenticated/hasRole/hasAnyRole, matched
top-to-bottom (specific before general) — for broad path policy.
Method-level. @EnableMethodSecurity + @PreAuthorize("hasRole('ADMIN')") on methods; the check runs
before the method (403 on failure), lives with the logic, and can reference arguments/principal — for
business-rule authorisation.
401 vs 403. 401 = not authenticated (no/invalid credentials — verified anon → 401); 403 = authenticated but not allowed (valid login, missing role). Preserve the distinction in error handling.
Object-level. Role checks cannot say "this specific record"; scope the query to the user (forbidden rows never fetched — nothing to leak), 404 for records they should not know exist. Layered: authenticated → role → object scope.
Practice
- Create STAFF and ADMIN users with
.roles(...); add a URL rule makingDELETE /api/hubs/**hasRole("ADMIN")and confirm a STAFF user gets 403 while ADMIN succeeds. - Put the specific DELETE rule after the general
/api/**rule and observe it never applies — order. - Enable
@EnableMethodSecurityand put@PreAuthorize("hasRole('ADMIN')")on a service method; confirm a non-admin call returns 403 and the method body does not run. - Distinguish, by testing, a 401 (no credentials) from a 403 (valid STAFF, ADMIN-only endpoint).
- Write a
myParcelsquery scoped to the authenticated username and confirm one user cannot see another's parcels. - Confuse
roles("ROLE_ADMIN")(double prefix) orhasAuthority("ADMIN")(missing prefix) and observe the check failing — then fix the prefix convention.
Official documentation
- Spring Security — Authorization — Roles, authorities, URL and method security.
- Spring Security — Method Security (@PreAuthorize) — Expression-based method authorisation.
- Spring Security — Authorize HTTP requests — URL-pattern rules and matchers.
Next: security mistakes that reach production.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship