Enterprise Java

Dynamic Link Generation Based on OAuth2 User Permissions

Most REST APIs treat pagination and navigation as a solved problem, and then quietly ignore a much messier one: what happens when two different users hit the same endpoint and get back two very different sets of things they’re allowed to do? A manager sees an “approve” link on an order. A regular employee sees the same order with that link nowhere in sight, not because the API is lying to them, but because it was never offered in the first place. That’s the promise of hypermedia done right, and it’s also where a lot of teams quietly give up and start hardcoding role checks on the frontend instead.

Let’s fix that.

What is dynamic link generation based on OAuth2 permissions?

Dynamic link generation based on OAuth2 permissions is the practice of conditionally attaching hypermedia links to an API response depending on the authenticated user’s granted authorities or scopes. Instead of returning every possible action for every user, the server inspects the OAuth2 principal at response-assembly time and includes only the links that user is actually authorized to follow.

Why does this matter more than it sounds like it should? Because REST without this behavior isn’t really REST, it’s just JSON with some decorative _links bolted on. The whole point of HATEOAS, hypermedia as the engine of application state, is that a client shouldn’t need to hardcode business rules about who can do what. It should just look at the response and see what’s actually available right now, for this user, in this state. Strip that away and you’re back to maintaining permission logic in two places: the backend that enforces it and the frontend that has to guess it.

Why static links leak capabilities you don’t have

Here’s a scenario that plays out constantly in growing APIs. A team builds an OrderController, wraps responses in EntityModel<Order>, and adds a tidy set of links: self, cancel, approve, ship. It works great in the demo. Then it ships, and a support engineer notices that regular users can see an approve link on orders they have zero authority to approve. Clicking it fails with a 403, sure, the security layer catches it, but the API just told an unauthorized user that approving orders is a thing they could try. That’s not a security hole exactly, but it’s a leak of information architecture, and it makes for a genuinely confusing client experience.

This is where a lot of teams reach for the wrong fix. They add if statements deep inside a RestController method, checking SecurityContextHolder directly and conditionally building the response object by hand. It works, technically, but it scatters authorization logic across every controller method that returns a linked resource, and it makes testing miserable. There’s a cleaner pattern, and Spring’s own security architecture documentation makes the building blocks pretty explicit once you know where to look.

How Spring HATEOAS lets you attach links conditionally

The trick is centering the decision inside a RepresentationModelAssembler, not the controller. Spring HATEOAS was designed with exactly this kind of conditional assembly in mind, and combining it with Spring Security’s Authentication object gives you a single place where permission logic lives.

@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> {

    @Override
    public EntityModel<Order> toModel(Order order) {
        EntityModel<Order> model = EntityModel.of(order,
            linkTo(methodOn(OrderController.class).getOrder(order.getId())).withSelfRel());

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (hasAuthority(auth, "ROLE_MANAGER")) {
            model.add(linkTo(methodOn(OrderController.class).approve(order.getId())).withRel("approve"));
        }

        if (hasAuthority(auth, "ROLE_WAREHOUSE") && order.getStatus() == OrderStatus.APPROVED) {
            model.add(linkTo(methodOn(OrderController.class).ship(order.getId())).withRel("ship"));
        }

        return model;
    }

    private boolean hasAuthority(Authentication auth, String authority) {
        return auth != null && auth.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals(authority));
    }
}

Notice what changed here, and it’s a small shift with a big payoff: the link isn’t just gated by role, it’s also gated by business state. A ship link only appears if the order is already APPROVED. That’s the real power of dynamic links, they encode both “are you allowed to” and “does it currently make sense” in one place, and a client that follows this pattern correctly never has to hardcode either rule.

The blue box represents the incoming request carrying the OAuth2 principal; the purple box is the assembler’s authority check; the teal and gray boxes show the two branches (link added, link omitted); the final blue bar represents the resulting EntityModel with only the permitted links attached. Conceptually adapted from patterns described in the Spring HATEOAS server-side support gui

The diagram above sketches this exact decision path, an authenticated principal flowing into the assembler, the assembler checking granted authorities, and two different outcomes depending on whether the check passes.

Roles, scopes, and why they’re not the same filter

Here’s a distinction that trips up a lot of teams once they move from basic role checks to real OAuth2 resource server setups: authorities and scopes aren’t interchangeable, and conflating them creates subtle bugs. A user’s ROLE_MANAGER authority tells you who they are inside your application’s own permission model. A token scope like orders:write tells you what the client application was authorized to do on that user’s behalf when the token was issued. A manager using a read-only reporting dashboard might have ROLE_MANAGER but a token scoped only to orders:read, and in that case, the approve link should still be withheld, because the client itself was never granted write access.

This is genuinely the kind of detail that separates a demo-quality implementation from a production one. Get this wrong and you’ll either over-expose links to under-scoped clients or, more commonly, under-expose them to a correctly-scoped, correctly-authorized user because someone only checked one of the two filters. Both checks need to pass independently.

private boolean hasScope(Authentication auth, String scope) {
    if (auth instanceof JwtAuthenticationToken jwtAuth) {
        List<String> scopes = jwtAuth.getToken().getClaimAsStringList("scope");
        return scopes != null && scopes.contains(scope);
    }
    return false;
}

// Inside toModel():
if (hasAuthority(auth, "ROLE_MANAGER") && hasScope(auth, "orders:write")) {
    model.add(linkTo(methodOn(OrderController.class).approve(order.getId())).withRel("approve"));
}
The outer amber container represents the full OAuth2 access token; the coral inner box lists identity-based authorities; the pink inner box lists client-based scopes; both compartments are checked independently, as the caption inside the diagram notes, before any link gets attached to a response.

The structural diagram above lays this out directly, an access token containing two separate compartments, authorities on one side and scopes on the other, both feeding into the same link decision independently. Teams building multi-client OAuth2 ecosystems, where a mobile app, a partner integration, and an internal admin tool all authenticate against the same authorization server, run into this pattern constantly, and OAuth2 resource server guidance on scope design is worth reading closely before you design your own scope taxonomy.

Testing this without losing your mind

Conditional links are notoriously easy to under-test, mostly because it’s tempting to only test the happy path where a fully-privileged admin sees everything. Don’t do that. Write tests for every meaningful permission combination: the user with no relevant authority, the user with the authority but the wrong scope, the user with both but the order in the wrong state. MockMvc combined with @WithMockUser or a custom JwtRequestPostProcessor makes this reasonably painless.

@Test
void warehouseUserWithoutApprovedOrderDoesNotSeeShipLink() throws Exception {
    mockMvc.perform(get("/orders/{id}", pendingOrderId)
            .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_WAREHOUSE"))))
        .andExpect(jsonPath("$._links.ship").doesNotExist());
}

One caching pitfall worth flagging explicitly: if you’re caching serialized responses at any layer, whether that’s an HTTP cache, a CDN, or an application-level cache, you cannot cache a permission-dependent EntityModel keyed only on resource ID. Two users hitting the same order will get wildly different link sets, and a naive cache will happily serve one user’s links to another. Vary your cache key on the authenticated principal’s relevant permission set, or better yet, don’t cache the assembled model at all and cache the underlying domain object instead, assembling links fresh on every request.

Frequently Asked Questions (FAQ)

Does dynamic link generation slow down API response times noticeably?
In practice, no. The authority and scope checks are in-memory operations against data already loaded into the security context, so the overhead is negligible, usually well under a millisecond per resource. The real performance risk isn’t the check itself, it’s accidentally caching permission-dependent responses incorrectly, which causes correctness bugs rather than slowness.

Should I use Spring Security’s method-level annotations like @PreAuthorize instead of manual checks in the assembler?
@PreAuthorize is excellent for blocking access to an endpoint entirely, but it’s the wrong tool for conditionally including or excluding a link inside an otherwise successful response. You still need the endpoint to succeed and return the resource, just without certain links. Keep @PreAuthorize for hard access control and keep link-level logic inside your assembler.

What happens if a client follows a link that later becomes invalid due to a permission change?
This is exactly why the state check matters as much as the role check, as shown in the order example above. If permissions or resource state change between when a client fetched the resource and when it follows the link, the server should still independently re-validate on the actual action endpoint. The link’s presence is a convenience and a hint, never a substitute for enforcement at the point of action.

Can this pattern work with attribute-based access control (ABAC) instead of simple roles?
Yes, and it scales naturally. Since the assembler is just a Java method, you can replace the simple authority check with a call to any policy engine, whether that’s Spring Security’s PermissionEvaluator, a custom rules service, or an external ABAC provider. The link-assembly pattern stays identical, only the decision logic underneath changes.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button