RizTech Academy logo
RizTech Academy
SecurityLesson 4 of 530 min

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

  1. Create STAFF and ADMIN users with .roles(...); add a URL rule making DELETE /api/hubs/** hasRole("ADMIN") and confirm a STAFF user gets 403 while ADMIN succeeds.
  2. Put the specific DELETE rule after the general /api/** rule and observe it never applies — order.
  3. Enable @EnableMethodSecurity and put @PreAuthorize("hasRole('ADMIN')") on a service method; confirm a non-admin call returns 403 and the method body does not run.
  4. Distinguish, by testing, a 401 (no credentials) from a 403 (valid STAFF, ADMIN-only endpoint).
  5. Write a myParcels query scoped to the authenticated username and confirm one user cannot see another's parcels.
  6. Confuse roles("ROLE_ADMIN") (double prefix) or hasAuthority("ADMIN") (missing prefix) and observe the check failing — then fix the prefix convention.

Official documentation

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