Skip to content

Vertique security is fail-closed, mechanism-neutral, and enforced from the same annotations you author with:

  • Fail-closed by design. A restrictive security annotation with no mechanism module to satisfy it fails application startup. An endpoint is explicitly public or explicitly guarded by a working mechanism. Otherwise the application refuses to start, so a misconfigured endpoint is never left open.
  • Mechanism-neutral runtime. The REST starter wires identity resolution, the authorization handler, and the SecurityContext bridge; you choose the mechanism. JWT bearer authentication ships as vertique-rest-auth-jwt.
  • Standard annotations. Guard a resource method with @RolesAllowed, @Authorized (scopes), or @DenyAll. When role and scope requirements are combined, both must pass.
  • JWKS-first key management. Point the JWT mechanism at your identity provider’s JWKS document, with non-blocking startup fetch and refreshing rotation support.
  • A typed security model. Read the caller’s identity, roles, and scopes from a typed SecurityContext in place of raw token claims. The Services execution path shares the same context.
  • Safe defaults. No forwarded header is trusted until you configure trusted proxies, and rejection reporting passes only stable, redacted reason codes.

By the end of this page you will understand what vertique-starter-rest secures by default and why, add JWT bearer authentication with vertique-rest-auth-jwt, and enforce role- and scope-based authorization on a resource method.

What the REST starter gives you by default

Section titled “What the REST starter gives you by default”

vertique-starter-rest’s RestApplicationModule (see Application model) already includes AuthModule and SecurityModule from vertique-rest-security. Those two modules wire the security runtime and its enforcement seams: identity resolution, the authorization handler, and the JAX-RS SecurityContext bridge. They do not supply a concrete way to verify who a caller is. No JWT (or any other) mechanism artifact is on the starter’s dependency ledger, and it contributes no SecuritySchemeHandler of its own.

That mechanism-neutral composition has one observable consequence for the HelloResource generated in Quickstart. Its hello method carries no @RolesAllowed, @Authorized, or OpenAPI security requirement, so the framework installs no auth handler on it and the request is public. The generated project’s own README says the same in its Security section: the /hello endpoint ships without authentication, and anyone who can reach the port can call it.

A restrictive annotation without a mechanism module does not fall back to that public behavior. It fails application startup, because no SecuritySchemeHandler exists to satisfy the declared requirement. See the “Common Mistakes” section of vertique-starter-rest’s module reference for this fail-closed rule. An endpoint is explicitly public or explicitly guarded by a working mechanism. Otherwise the application refuses to start, so a misconfiguration never leaves an endpoint open.

Add the mechanism module you want as an explicit dependency and name it in your own component. vertique-rest-auth-jwt is the framework’s JWT bearer implementation, and the public vertique-example-hello example depends on it directly. The dependency has no explicit version, the same way the generated project depends on vertique-starter-rest without one, because both parent to a Vertique parent POM that manages the version through the BOM:

<dependency>
<groupId>dev.vertique</groupId>
<artifactId>vertique-rest-auth-jwt</artifactId>
</dependency>

JwtAuthModule composes AuthModule and SecurityModule itself, so naming it in your component reaches the same security runtime RestApplicationModule already wires, plus the JWT-specific bindings on top. vertique-example-hello’s own AppComponent names it alongside its JAX-RS, validation, and management modules (trimmed to the modules this page covers):

@VertiqueApp
@Singleton
@Component(
modules = {
VertxModule.class,
RestModule.class,
RestValidationModule.class,
JwtAuthModule.class,
ManagementModule.class,
AppModule.class,
ResourceModule.class,
GeneratedJaxRsResourcesModule.class
})
interface AppComponent extends VertiqueApplicationComponent {}

JwtAuthModule still needs a JWTAuth binding from your own application module. vertique-example-hello’s AppModule builds one from a hardcoded symmetric HMAC key, for demonstration purposes only:

@Provides
@Singleton
static JWTAuth jwtAuth(Vertx vertx) {
return JwtAuthFactory.fromSymmetricKey(
vertx, "HS256", "super-secret-key-for-example-app-minimum-256-bits-long!!");
}

A production application should not hardcode a symmetric key this way. Point JwtAuthFactory at your identity provider’s JWKS document. Use fromJwks(vertx, location) for a classpath: or filesystem location. Use fromJwksAsync(vertx, location) to fetch an http:///https:// location without blocking the event loop during startup. Use fromJwksRefreshing(vertx, location, interval) when the provider rotates its signing keys, so token validation keeps working through a rotation without restarting the application. A production JWKS location must be https://. JwtAuthFactory logs a startup warning when a JWKS fetch uses plain http://, because an on-path attacker could substitute the signing keys in transit. A plain-HTTP location is acceptable only for local development. See the vertique-rest-auth-jwt module reference for the full method reference and the async startup sequence.

Declare the scheme your OpenAPI configuration exposes, matching vertique-example-hello’s OpenApiConfig:

@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT")
public class OpenApiConfig {}

bearerAuth also matches JwtAuthConfig’s default scheme name. Constrain which tokens are accepted with the jwt section of your application config:

{
"jwt": {
"schemeName": "bearerAuth",
"validation": {
"issuer": "https://auth.example.com/",
"audience": ["https://api.example.com"],
"clockSkewSeconds": 30
}
}
}

Every field is optional and defaults when omitted. clockSkewSeconds defaults to 30 even when the whole validation object is left out. Omitting issuer or audience removes that check entirely. JwtBearerSecuritySchemeHandler enforces the configured iss/aud claims after authentication, independently of how the JWTAuth was built, so leaving either key unset lets a token from any issuer or audience through. JwtAuthFactory logs its own startup warning for an unset issuer or audience only when its JwtValidationConfig-aware overload builds the JWTAuth directly. The no-config fromJwks/fromJwksAsync/fromJwksRefreshing overloads recommended above emit no such warning. A production configuration should still set both issuer and audience to prevent token substitution attacks. See the module reference’s configuration table for every key, and its “Extension Points” section for overriding the whole JwtAuthConfig from a @Provides binding in place of the config file.

After a JWT is verified, the mechanism also binds a session reference into the correlation context, taken from the token’s sid claim, or from jti when sid is absent. Requests made with the same session can then be correlated. jwt.sessionCorrelation.claimPreference changes which claims are tried and in what order, and jwt.sessionCorrelation.enabled set to false turns the binding off.

Enforce authorization on a resource method

Section titled “Enforce authorization on a resource method”

Once a mechanism is wired, guard a resource method with @RolesAllowed, @Authorized, or @DenyAll and read the authenticated caller from an injected SecurityContext. vertique-example-hello’s HelloResource demonstrates the pattern this page reconciles against:

@GET
@Path("/secured")
@RolesAllowed("user")
public Future<GreetingResponse> securedGreeting(SecurityContext sc) {
String userId = sc.identity().actor().id();
return Future.succeededFuture(new GreetingResponse("Hello, " + userId + "! (secured)"));
}
@GET
@Path("/scoped")
@RolesAllowed("user")
@Authorized(scopes = "write")
public Future<GreetingResponse> scopedGreeting(SecurityContext sc) {
String userId = sc.identity().actor().id();
return Future.succeededFuture(new GreetingResponse("Scoped access: Hello, " + userId + "!"));
}

@Authorized is dev.vertique.security.authz.Authorized from vertique-security-core. sc.identity().actor().id() reads the authenticated caller’s id from the typed SecurityContext. sc.authorization().valuesOf(AuthorityKind.SCOPE) reads its granted scopes the same way. Combining @RolesAllowed and @Authorized on one method, as scopedGreeting does above, requires both to pass: a caller with the user role but without the write scope is rejected. The same example’s integration test proves the resulting behavior. A request with no Authorization header gets 401, a valid token missing the required role or scope gets 403, and a token carrying both gets 200.

A method with no security annotation and no OpenAPI security requirement (like hello in the plain, unsecured HelloResource) stays public even once a mechanism module is installed. Adding a mechanism does not lock down every existing route. @DenyAll always returns 403, even for an otherwise valid, fully authorized token. See vertique-rest-security’s module reference for the complete authorization annotation table and the OR-of-AND-with-scopes model behind it.

A reusable requirement set is an AccessPolicy: a public interface that extends only dev.vertique.security.authz.AccessPolicy and carries the same direct annotations. Name it with @RequiresPolicy on the type or the method. A method reference replaces a type reference. REST expands that policy into the same role, scope, and action checks as the inline annotations.

import dev.vertique.security.authz.AccessPolicy;
import dev.vertique.security.authz.RequiresPolicy;
@RolesAllowed("admin")
public interface AdminPolicy extends AccessPolicy {}
@GET
@RequiresPolicy(AdminPolicy.class)
public Future<GreetingResponse> adminGreeting(SecurityContext sc) {
return Future.succeededFuture(new GreetingResponse("admin"));
}

The same type also decides who may read an application’s generated OpenAPI document: @ApiDocs(policy = AdminPolicy.class, securityScheme = "bearerAuth") serves it only to callers that scheme authenticates and the policy admits, while a policy that is exactly @PermitAll publishes it to everyone and takes no scheme. @ApiDocs takes only policy and securityScheme; see Generate an OpenAPI document.

Earlier releases shipped the annotation as dev.vertique.rest.core.security.Authorized in vertique-rest-core. It is dev.vertique.security.authz.Authorized in vertique-security-core, and the old type has no compatibility alias. Update the import, make sure vertique-security-core is on the application’s compile classpath, and do a clean rebuild with matching framework and JAX-RS annotation-processor versions.

Customize claim mapping and authorization decisions

Section titled “Customize claim mapping and authorization decisions”

The default claim mapper reads roles, scope/scp, and permissions claims from the token, each accepting either a JSON array or a space-delimited string. To map claims to roles and scopes by your identity provider’s own conventions, provide a custom SecurityClaimMapper binding. To change how a role/scope requirement is evaluated, for example against a remote policy decision point, provide a custom AuthorizationPolicy or AuthorizationDecisionPoint binding. Both extension points, their contracts, and worked examples are covered in the vertique-rest-security module reference.

A decision point that calls a remote system cannot hold a request open indefinitely. Every role, scope, and action gate is bounded by a deadline of 5000 milliseconds, shared by REST, WebSocket, and MCP. A gate that does not answer in time fails closed: the REST caller receives 503, and a real denial stays 403. To change the deadline, install AuthorizationGateConfigModule next to AuthModule and set security.authz.gateDeadlineMs.

The typed SecurityContext, SecurityIdentity, and authorization model these seams operate on are defined in vertique-security-core, independent of transport. The same model authorizes typed service contracts in the Services execution path.

The same authorization model governs the MCP tool-list and tool-call endpoints. A custom policy binding therefore applies at both the REST and the MCP boundary unless it branches on the invocation origin.

Vertique’s MCP server (see the vertique-mcp-server module reference) applies the same @RolesAllowed, @DenyAll, and @RequiresAction annotations to a generated tool, evaluated through the same SecurityPolicyEnforcer and AuthorizationDecisionPoint a REST resource method uses. MCP has no separate decision engine and no separate policy model. A custom AuthorizationPolicy or AuthorizationDecisionPoint binding you provide for REST therefore also governs MCP tool calls. It may decide differently per transport, since MCP supplies its own resource and origin to that same decision point.

An unannotated tool, or one marked @PermitAll, is public to anonymous and authenticated callers. @DenyAll is excluded from tool listing and from calling. @RolesAllowed and @RequiresAction, alone or combined, require an authenticated, authorized caller. A denied tool and an unknown tool name resolve to the same protocol response, with the same JSON-RPC error code, message, and HTTP status, so the response itself does not reveal which restricted tools exist. Response timing can still differ: a restricted tool denied by a remote policy decision point takes a network round trip, while an unknown name settles locally. If timing equivalence matters, use opaque tool identifiers, or decide from claims already on the identity snapshot instead of a per-request remote call. See the vertique-mcp-server module reference for the complete authorization matrix.

MCP supplies DispatchBoundary.MCP in its invocation origin and a resource of type mcp-tool with the generated tool name as its id. Use those typed values when a policy must distinguish MCP from REST; do not infer the transport from an HTTP route or request body.

Write a shared policy so that it fails closed on an origin it does not recognize. A policy written only against the REST verbs, such as a denylist that rejects DELETE for non-admins and permits everything else, falls through to permit for every role- or scope-constrained MCP tool, because on the role and scope decision an MCP request’s action() is the tool name rather than an HTTP method. Unannotated, @PermitAll, and @DenyAll tools never reach the policy. Branch on the origin or the mcp-tool resource type first, permit only the combinations the policy was written for, and deny any other origin kind.

Mind the shape of the authorization decision events MCP emits. On MCP, a restrictive tool with no @RequiresAction, for example one guarded only by @RolesAllowed, produces permitted=true together with actionSatisfied=false and actionEvaluated=false, because no action gate ran. Read permitted for the outcome and actionEvaluated to tell whether an action gate ran. An external correlation or alerting rule that treats actionSatisfied=false as a denial misclassifies these events.

Keep DEBUG logging off for dev.vertique.rest.security and dev.vertique.security in production. When the default decision point denies a request on its role or scope check, it logs the required and the granted role, scope, and permission claims at DEBUG. MCP reaches that path, and tools/list examines candidate tools until the page is full or the examination budget is spent, so one list request logs one line per examined candidate denied on its role or scope claims.

Input-processing policies have a related migration edge. Tool parameters are traversed with InputLocation.PAYLOAD at every depth, never InputLocation.BODY. An existing canonicalizer, sanitizer, or character policy that branches only on BODY does not cover MCP tool arguments. Add the PAYLOAD branch explicitly before publishing the tool; there is no compatibility alias between the two locations.

RequestOriginConfig, which controls how a captured client IP, forwarded scheme, and forwarded host are trusted, defaults to no trusted proxy at all: an empty CIDR set. Behind a load balancer or reverse proxy, override this binding in your own Dagger module so the framework only trusts forwarded headers from peers you control. See the module reference above for the binding shape and every field it accepts.

Never log or return raw token material, raw API keys, or raw request bodies from a custom CredentialRejectionReporter or claim mapper. The framework’s own rejection reporting passes only stable, redacted reason codes and safe attributes to its emitted events, and a custom implementation should follow the same discipline.