Security
View MarkdownVertique 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
SecurityContextbridge; you choose the mechanism. JWT bearer authentication ships asvertique-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
SecurityContextin 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 an authentication mechanism
Section titled “Add an authentication mechanism”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@Singletonstatic 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.
Migrate the annotation import
Section titled “Migrate the annotation import”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.
Extend authorization to MCP tools
Section titled “Extend authorization to MCP tools”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.
Safe defaults and hardening pointers
Section titled “Safe defaults and hardening pointers”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.
Learn more
Section titled “Learn more”vertique-rest-securitymodule reference: identity resolution, the authorization model, and every extension point on this page.vertique-rest-auth-jwtmodule reference:JwtAuthModule,JwtAuthFactory, and the fulljwtconfiguration reference.vertique-security-coremodule reference: the typedSecurityContext/SecurityIdentity/authorization model, independent of REST.vertique-mcp-servermodule reference: the frozen MCP tool authorization matrix reusing this same authorization model.vertique-starter-restmodule reference: the mechanism-neutral aggregate and its fail-closed startup behavior.vertique-example-hello: the full worked resource this page reconciles against, including its integration test proving the401/403/200outcomes above.- Quickstart
- Application model
- REST APIs
- Services
- What is Vertique?