Skip to content

REST security

View Markdown

Status: Stable Package: dev.vertique.rest.security Artifact: vertique-rest-security Depends on: rest-core, security-core, security-runtime, context, logging, resilience API reference: javadoc.io

vertique-rest-security is the authentication, identity-resolution, and authorization layer for Vertique’s HTTP transport. It turns whatever an authentication handler proved about a request into the framework’s typed dev.vertique.security.SecurityContext, enforces the operation’s declared security policy before the resource method runs, and emits a canonical security event for every credential acceptance, credential rejection, and authorization decision.

It does not verify credentials itself. Credential verification is contributed by an authentication module — dev.vertique:vertique-rest-auth-jwt for bearer tokens, or an application’s own SecuritySchemeHandler / RouteAuthHandler. This module consumes the evidence those handlers produce. It also does not manage users, sessions, or login flows.


Install vertique-rest-security whenever an HTTP operation carries @RolesAllowed, @DenyAll, @PermitAll, @Authorized, @RequiresAction, or a Swagger @SecurityRequirement. Without it the route registrar has no AuthEnforcementCapability binding and rejects those annotations at startup, so a secured API cannot start unauthenticated by accident.

Pair it with:

Artifact Why
dev.vertique:vertique-rest-auth-jwt Ready-made bearer-token SecuritySchemeHandler and RouteAuthHandler
dev.vertique:vertique-security-core The typed SecurityContext model, AuthorizationClaims, @RequiresAction, AuthorizationPolicy
dev.vertique:vertique-security-runtime SecurityEventEmitter and the SecurityEventObserver multibinding
dev.vertique:vertique-security-authz The action Authorizer engine that @RequiresAction requires

Every request passes three separately-owned stages. Only the last two belong to this module.

  1. Authentication — an AuthenticationHandler installed from the operation’s security scheme sets ctx.user() and appends AuthenticationEvidence. On failure it calls CredentialRejectionReporter.report(...) and then ctx.fail(...), which short-circuits everything below.
  2. Identity resolution (priority 80) — runs the SecurityIdentityResolver chain, maps the Vert.x User principal to AuthorizationClaims, and — only when the opt-in VertxAuthorizationImportModule is included and the request is authenticated — imports the grants of every contributed Vert.x AuthorizationProvider into those claims (see Vert.x authorization import); then assembles the SecurityContext, binds it for the rest of the request, and emits CredentialAcceptedEvent. IdentityResolutionMiddleware.handle(...) binds the REST invocation origin by default; another transport reusing the same resolver must use handlerFor(InvocationOrigin) to bind its explicit boundary identity instead.
  3. Authorization (priority 100) — evaluates the effective SecurityPolicy and any @RequiresAction gate, and emits exactly one AuthorizationDecisionEvent.

The framework occupies these OperationHandlerContributor priorities. Choose a priority for your own contributor relative to them:

Priority Contributor Artifact
40 ActionGateAuthenticationContributor vertique-rest-security
50 JWT claims validator vertique-rest-auth-jwt
80 IdentityResolutionContributor vertique-rest-security
100 AuthorizationContributor vertique-rest-security

OriginCaptureMiddleware is the module’s one ROOT-scoped Middleware. It runs before any authentication handler and stashes the resolved RequestOrigin so identity resolution and every emitted event carry the caller’s address even when authentication fails.

RequestOriginCapturer.capture never throws and never returns null. A part it cannot resolve (for example the peer address on a transport without one) is RequestOrigin.UNKNOWN, the port is 0, and an unresolved peer is never treated as a trusted proxy, so forwarded headers cannot name its client. Check RequestOrigin.clientIpKnown() before using clientIp() as an identifier.

dev.vertique.security.SecurityContext has four pillars, assembled at identity resolution:

Pillar Source
identity() the first SecurityIdentityResolver returning a non-empty result; SecurityIdentity.anonymous() when the chain is exhausted
authentication() the accumulated AuthenticationEvidence list; primaryMethod() is the first entry’s method, or DefaultAuthMethod.none() when there is no evidence
authorization() SecurityClaimMapper applied to ctx.user().principal(), plus any grants imported through the opt-in Vert.x authorization import; AuthorizationClaims.empty() when no Vert.x User is present
origin() the RequestOrigin captured pre-authentication

Inject it into any resource method (see JAX-RS integration), or read it anywhere in the request through SecurityRuntime.current(). The binding unwinds with the request lifecycle. SecurityRuntime.clearCurrent() discards the current holder binding without restoring a prior value — reserved for trust-boundary clears (MCP’s no-scheme admit path) that must not leave a foreign ambient identity visible before the transport binds its own. Prefer bindCurrent with lifecycle-owned scopes for ordinary bind/unbind. The same context is captured onto outbound vertique-services dispatches automatically, so a downstream service handler observes the caller’s identity without threading it through the contract.

Authorization model: OR of AND, with scopes

Section titled “Authorization model: OR of AND, with scopes”

An operation’s security requirements follow OpenAPI semantics through RestOperationDescriptor.securityRequirementSets():

  • the list of SecurityRequirementSet is an OR — satisfying any one set authenticates the request;
  • every SecurityRequirement inside a set must hold — an AND;
  • every scope listed on a scheme must hold — an AND;
  • an empty list means the operation is public.
// dev.vertique.rest.core.routing — from vertique-rest-core
public record SecurityRequirement(String schemeName, List<String> scopes) {}
public record SecurityRequirementSet(List<SecurityRequirement> schemes) {
public boolean isSingleScheme(); // exactly one scheme in this set
public boolean hasScopes(); // at least one scheme carries required scopes
}

Sets are produced from annotations: a standalone @SecurityRequirement(name) becomes a single-scheme set; @SecurityRequirement(combine = {…}) becomes a multi-scheme AND-set; repeated @SecurityRequirement, @SecurityRequirements, or @Operation(security = …) become multiple sets (the OR alternatives).

Any security shape the framework cannot enforce exactly as declared fails startup with RestConfigurationException. Nothing is silently downgraded or ignored.

Declared shape Outcome
Empty securityRequirementSets() Public — no authentication handler installed
One single-scheme set, no scopes Authentication handler installed; no scope check
One single-scheme set, with scopes Authentication handler plus scope enforcement (folded, below)
Several single-scheme scopeless sets ChainAuthHandler.any() — any one scheme is sufficient
Any multi-scheme AND-set (combine()) RestConfigurationException at startup
Several sets where any set carries scopes RestConfigurationException at startup
Scopes declared via both @Authorized(scopes) and @SecurityRequirement RestConfigurationException at startup — ambiguous
Two SecuritySchemeHandlers with the same schemeName() RestConfigurationException at startup

The gate is EffectiveSecurityPolicy.enforceSupportedShape in vertique-rest-core. Reading an operation’s effective policy runs it, so the check is always on — it does not depend on this module’s SecurityPolicyValidator being wired.

Scope enforcement is unified with @Authorized

Section titled “Scope enforcement is unified with @Authorized”

For the one scoped shape the framework enforces — a single single-scheme set with scopes — EffectiveSecurityPolicy.fold merges the scheme’s scopes into the operation’s policy at route registration:

Base policy Effective policy after fold
None or AuthenticatedOnly Constrained([], scopes, requireAllScopes = true)
Constrained(roles, [], …) Constrained(roles, scopes, requireAllScopes = true)
PermitAll or DenyAll unchanged

Roles from @RolesAllowed / @Authorized(roles) survive the fold. Enforcement then flows through the same AuthorizationDecisionPoint → SecurityPolicyEnforcer → 403 path as @Authorized(scopes) — one mechanism, one status, one event. fold never replaces or loosens scopes a base policy already declares.

Every decision this module makes produces exactly one canonical event, fanned out to every SecurityEventObserver through the SecurityEventEmitter from dev.vertique:vertique-security-runtime. Observer failure is isolated and never changes the security outcome.

Event Emitted by When
CredentialRejectedEvent the failing authentication handler, via CredentialRejectionReporter credential verification failed
CredentialAcceptedEvent identity resolution evidence is non-empty and a CorrelationContext is bound
AuthorizationDecisionEvent SecurityPolicyEnforcer once per authorization attempt, permit or deny
ChannelOpenedEvent, ChannelIdentityRefreshedEvent, ChannelClosedEvent ChannelIdentityManager long-lived channel lifecycle

An AuthorizationDecisionPoint is a pure evaluator: it returns a decision and must not emit. The enforcement layer owns emission, so a decision point that also emits produces duplicate events.


@Singleton
@Component(modules = {VertxModule.class, RestModule.class,
AuthModule.class, SecurityModule.class,
AppModule.class, ResourceModule.class})
interface AppComponent {
HttpVerticle httpVerticle();
}

SecurityModule supplies the SecurityRuntime binding and is required for any security feature. AuthModule adds identity resolution, authorization, the startup validator, and the default claim mapper, rejection reporter, and channel identity manager.


Static bridge an authentication handler uses to record what it proved. Evidence accumulates as a list on the routing context, so layered authentication (mTLS plus JWT) contributes several entries.

RestAuthenticationEvidence.append(ctx, new AuthenticationEvidence(
DefaultAuthMethod.jwt(),
Optional.of(jti), // credentialId — stable and non-sensitive
Instant.now(), // verifiedAt
Optional.of(expiresAt), // notAfter
new JwksVerificationSource(
Optional.of("https://idp.example.com/"),
Optional.of("https://idp.example.com/.well-known/jwks.json"),
Optional.of(kid),
Optional.of("RS256")),
Map.of())); // safeAttributes

RestAuthenticationEvidence.get(ctx) returns the accumulated list; identity resolution reads it. The first entry’s method becomes authentication().primaryMethod(), so append the strongest or primary method first.

SPI an authentication handler calls when verification fails, before ctx.fail(...).

void report(RoutingContext ctx,
AuthMethod attemptedMethod,
Optional<String> credentialId,
Optional<VerificationSource> verificationSource,
String reasonCode,
Map<String, Object> safeAttributes);
rejectionReporter.report(
ctx,
DefaultAuthMethod.jwt(),
Optional.of(jti),
Optional.of(new JwksVerificationSource(
Optional.of(issuer), Optional.of(jwksUri), Optional.empty(), Optional.empty())),
"TOKEN_EXPIRED",
Map.of("header_alg", headerAlg));
ctx.fail(401);

safeAttributes must not contain raw token material, raw API keys, raw passwords, raw HMAC signatures, or raw request bodies. It throws IllegalStateException when no CorrelationContext is bound; in normal request flow the correlation ingress middleware binds one before any authentication handler runs.

Functional SPI mapping raw provider claims to typed AuthorizationClaims:

AuthorizationClaims map(Map<String, Object> claims);

The default implementation reads three conventions. Each claim may be a JSON array of strings or a single space-delimited string; non-string elements and blank values are skipped, and a missing claim contributes nothing.

Claim Authority kind
roles AuthorityKind.ROLE
scope and scp (merged) AuthorityKind.SCOPE
permissions AuthorityKind.PERMISSION

Override it for a provider with a different shape:

@Provides
SecurityClaimMapper keycloakClaimMapper() {
return claims -> {
// Keycloak nests roles under realm_access.roles.
Set<AuthorityClaim> authorities = extractNestedRoles(claims, "realm_access", "roles").stream()
.map(role -> new AuthorityClaim(AuthorityKind.ROLE, role, "", "", "", Map.of()))
.collect(Collectors.toSet());
return new AuthorizationClaims(authorities, Map.of());
};
}

AuthorityClaim is (kind, value, issuer, audience, source, attributes); the built-in mapper leaves issuer, audience, and source empty. Query the result with claims.valuesOf(AuthorityKind.ROLE).

Async authorization SPI. Implementations receive a fully-populated AuthorizationRequest and return Future<AuthorizationDecision>.

@FunctionalInterface
public interface AuthorizationDecisionPoint {
Future<AuthorizationDecision> decide(AuthorizationRequest request);
}
@Provides
AuthorizationDecisionPoint remoteDecisionPoint(MyPdpClient client) {
// Return the decision only — SecurityPolicyEnforcer emits the one event.
return request -> client
.evaluate(request.securityContext(), request.action(), request.resource())
.map(allowed -> allowed
? AuthorizationDecision.permit("REMOTE_PDP_ALLOWED")
: AuthorizationDecision.deny("REMOTE_PDP_DENIED"));
}

Contributed Vert.x AuthorizationProvider bindings are consulted at identity-resolution time — and only when the opt-in VertxAuthorizationImportModule is included (see Vert.x authorization import); their grants are merged into the resolved AuthorizationClaims before authorization runs. Decisions themselves are always evaluated from AuthorizationClaims — no decision point talks to a provider.

Builds the Handler<RoutingContext> that enforces a SecurityPolicy, optionally AND-composed with a @RequiresAction gate. AuthorizationContributor uses it for JAX-RS routes; reuse it directly when registering non-JAX-RS routes (for example a WebSocket upgrade) that need the same enforcement. The enforcer is transport-neutral: when no ambient InvocationOrigin is bound it falls back to InvocationOrigin.unspecified(), never to a privileged rest origin (DEF-007). Each transport’s identity step must bind its own origin before authorization — REST via IdentityResolutionMiddleware, non-REST via IdentityPipelineFactory.identityResolutionHandler. Origin-aware policies and narrowers that allowlist interactive ingress must treat unspecified as deny, not as REST.

Method Purpose
createHandler(SecurityPolicy) Role/scope enforcement only
createHandler(SecurityPolicy, Optional<ActionRef>) AND-composes the role/scope gate with the action gate into one handler emitting one event
createHandler(SecurityPolicy.Constrained, String) Constrained enforcement with a context label used in error messages
decide(SecurityContext, SecurityPolicy, Optional<ActionRef>, ResourceRef, InvocationOrigin) Non-HTTP counterpart to createHandler(SecurityPolicy, Optional<ActionRef>): returns the composed decision instead of installing a handler

It returns null — install no handler — for None and PermitAll with no action. With an action present, even an action-only None route gets a handler.

The decision point is selected once, at construction, in this order:

  1. an application-provided AuthorizationDecisionPoint;
  2. an application-provided sync AuthorizationPolicy, wrapped as SyncPolicyDecisionPoint;
  3. the built-in decision point, which evaluates roles, scopes, and permissions from AuthorizationClaims by delegating to ClaimAuthorizationPolicy in vertique-security-runtime. Its requiredRoles, requiredScopes and requireAllScopes context keys and its reason codes are the same constants, so their spelling is unchanged.

decide(...) is for a caller with no RoutingContext to drive — for example a non-HTTP-routed transport that has already established a SecurityContext for the caller. It mirrors createHandler(SecurityPolicy, Optional<ActionRef>) exactly: the same role/scope-plus-action AND composition, the same fail-fast ordering (the action gate is evaluated only once the role/scope gate permits), the same first-failing-predicate reasonCode, and the same rolesSatisfied / actionSatisfied / actionEvaluated safe attributes — but it returns the AuthorizationDecision instead of driving a RoutingContext, performs no HTTP status mapping, and never resolves ambient state; the caller supplies an already-established SecurityContext and its own ResourceRef and InvocationOrigin. None and PermitAll with no action permit and emit no event, matching the handler factories that install no handler for the same shape; DenyAll denies with AuthzReasonCodes.DENY_ALL and emits one event; every other combination emits exactly one combined AuthorizationDecisionEvent. The returned future is never null and never fails for an ordinary deny — a contract-violating decision point or Authorizer resolves a fail-closed INTERNAL_AUTHZ_ERROR deny instead of propagating. An invalid input, such as a Constrained policy with neither roles nor scopes, also resolves an INTERNAL_AUTHZ_ERROR deny without consulting either gate. Unlike createHandler, which rejects that policy at startup, decide(...) does not throw; it logs a warning and emits no event. vertique-mcp-server is the framework’s own caller, using it to authorize a tool invocation against the caller’s already-resolved SecurityContext instead of a Vert.x route.

Gate deadline (AuthorizationGateConfig). Every role/scope and action gate future that decide and every handler SecurityPolicyEnforcer builds awaits is bounded by a deadline. A non-blocking future from an app-provided AuthorizationDecisionPoint or Authorizer that simply never resolves (a remote PDP or OPA sidecar with no timeout of its own) would otherwise stall the caller indefinitely, which is a real failure for a caller with no RoutingContext idle timeout to fall back on (an MCP tools/list scan) and leaves a REST or WebSocket request open. The bound is a timeout-only pipeline of the application’s Resilience runtime, built once per gate from the configured deadline. A gate whose future has already settled when it is returned is not timed at all, so an in-memory decision point costs the runtime nothing; a pending gate settles on the request’s Vert.x context and is reported to any installed ResilienceObserver as an execution — a timed-out one when the deadline elapses, a successful one when the gate answers in time. The timeout does not cancel the gate’s own work. A policy client that fails with its own plain TimeoutException is treated the same way as a gate that exceeded the deadline. Exceeding the deadline fails closed with AuthzReasonCodes.INTERNAL_AUTHZ_ERROR — the marker for “authorization could not decide”, as opposed to a denial — and exactly one emitted AuthorizationDecisionEvent, with no propagation of the stalled future. decide returns that decision; a REST or WebSocket handler answers 503 with the generic Authorization is temporarily unavailable, never a resilience failure text. When the Resilience runtime has closed at application shutdown, a gate that is still pending is treated the same way at once. See Configuration for the operator key and default.

The single assembly point for the identity pipeline: it holds every security collaborator once and hands out the identity-resolution middleware and the policy enforcer that transports install. AuthModule provides it. A transport that must also work without security injects Optional<IdentityPipelineFactory> and installs what it needs from it, instead of re-assembling the pipeline from the individual collaborators.

Method Returns
securityRuntime() the SecurityRuntime the pipeline binds resolved contexts into
restIdentityResolution() the REST IdentityResolutionMiddleware — the instance AuthModule binds
identityResolutionHandler(IdentityPipelineOptions) the Handler<RoutingContext> a non-REST transport installs as its identity step
policyEnforcer() the graph’s one SecurityPolicyEnforcer

Every accessor is memoized, so one factory yields one middleware per capture choice and one enforcer. identityResolutionHandler is the only accessor that honours an origin; there is deliberately no accessor taking options and returning a middleware, because a middleware’s handle(ctx) always binds the REST origin. It rejects the REST origin itself with IllegalArgumentException — REST goes through restIdentityResolution() — so no other transport can label its decisions rest. Do not construct the factory in application code: memoization is per instance, so a second factory yields a second enforcer and middleware that share neither the gate deadline nor the security-event observers.

The class has no @Inject constructor by design: Dagger cannot declare @BindsOptionalOf for an @Inject-constructible type, and a transport that works without security must be able to declare the pipeline optional. AuthModule is therefore its only binding — do not declare the collaborator bindings it consumes in another module, or a graph can assemble a second pipeline that transports do not share.

record IdentityPipelineOptions(InvocationOrigin origin, boolean identitySnapshotCapture) — what a transport asks the factory to assemble for it. Use the presets; they are the supported combinations.

Preset origin identitySnapshotCapture
IdentityPipelineOptions.rest() IdentityResolutionMiddleware.REST_ORIGIN — kind DispatchBoundary.REST ("rest") true
IdentityPipelineOptions.webSocket() InvocationOrigin.of(DispatchBoundary.WEBSOCKET) ("websocket") false

identitySnapshotCapture = false assembles the middleware with no capture seam at all, so no identity snapshot is taken at that transport’s ingress even when the application installs identity-snapshot carriage. A channel upgrade establishes a long-lived identity rather than a per-request one, so it deliberately does not capture at upgrade.

// A non-REST transport's identity step and enforcement, from one optional binding.
Handler<RoutingContext> identityStep =
pipeline.identityResolutionHandler(IdentityPipelineOptions.webSocket());
SecurityPolicyEnforcer enforcer = pipeline.policyEnforcer();

Bridges the framework context to jakarta.ws.rs.core.SecurityContext so standard JAX-RS code works unchanged.

JAX-RS method Behavior
getUserPrincipal() the actor id as a Principal; null when the actor is anonymous or no framework context is bound
isUserInRole(role) matches a ROLE authority claim by value; always false for an anonymous actor, regardless of claims
isSecure() origin().scheme() equals "https"; falls back to the raw request’s TLS state when no origin was captured
getAuthenticationScheme() see the mapping below

getAuthenticationScheme() maps authentication().primaryMethod().normalizedKind():

Kind Returned string
JWT "BEARER"
BASIC "BASIC" (SecurityContext.BASIC_AUTH)
API_KEY "API_KEY"
MTLS "CLIENT_CERT" (SecurityContext.CLIENT_CERT_AUTH)
HMAC "HMAC"
CUSTOM, UNKNOWN the auth method’s id(), upper-cased
NONE null

getAuthenticationScheme() also returns null when no framework context is bound.

Registry of identities bound to long-lived channels (WebSocket, SSE), keyed by channel id. The default implementation is in-memory and, for every channel whose AuthenticationState.earliestNotAfter() is present, schedules a timer that closes the channel with reason code IDENTITY_EXPIRED at that instant.

Method Effect
register(channelId, ctx, binding) records the identity, emits ChannelOpenedEvent, arms the expiry timer
refreshIdentity(channelId, newCtx) rebinds on the channel’s event loop, emits ChannelIdentityRefreshedEvent, re-arms the timer
closeChannel(channelId, reasonCode) server-initiated close with an explicit reason
deregister(channelId, fallbackReasonCode) peer-initiated close: cancels the timer, emits ChannelClosedEvent, releases the binding last
current(channelId) the currently bound context, if any

The binding is released after the close event, so an @OnClose callback still observes the bound SecurityContext.


Contribute an identity resolver. SecurityIdentityResolver extends OrderedExtension, so the chain is sorted by phase, then ascending priority(), then orderKey() — which delegates to id(). The first resolver returning a non-empty Optional wins; an exhausted chain yields SecurityIdentity.anonymous(). The framework default runs at priority 100, so an application resolver should use a priority below 100 to run first.

@Provides @IntoSet
static SecurityIdentityResolver tenantResolver(TenantDirectory directory) {
return new SecurityIdentityResolver() {
@Override
public int priority() {
return 50;
}
@Override
public Future<Optional<SecurityIdentity>> resolve(SecurityIdentityResolutionContext ctx) {
return ctx.evidence().stream()
.filter(e -> e.method().normalizedKind() == AuthMethodKind.API_KEY)
.findFirst()
.map(e -> directory.lookup(e.credentialId().orElse("")).map(Optional::of))
.orElse(Future.succeededFuture(Optional.empty()));
}
};
}

Contribute the authentication handler for a named OpenAPI security scheme.

@Provides @IntoSet
SecuritySchemeHandler jwtScheme(JWTAuth jwtAuth) {
return new SecuritySchemeHandler() {
@Override
public String schemeName() {
return "bearerAuth";
}
@Override
public void configure(SecuritySchemeRegistry registry) {
registry.authenticationHandler(JWTAuthHandler.create(jwtAuth));
}
};
}

The registry takes an AuthenticationHandler, not a bare Handler<RoutingContext>, so several single-scheme scopeless alternatives can be composed into a Vert.x ChainAuthHandler.any(). Two handlers reporting the same schemeName() fail startup.

Route-level authentication for transports with no OpenAPI security scheme — WebSocket endpoints and action-only @RequiresAction routes.

@Provides @IntoSet
RouteAuthHandler bearerRouteAuth(JWTAuth jwtAuth) {
return new RouteAuthHandler() {
@Override
public String schemeName() {
return "bearerAuth";
}
@Override
public Handler<RoutingContext> createHandler() {
return JWTAuthHandler.create(jwtAuth);
}
};
}

createHandler() is required authentication. Implement createOptionalHandler() only when the scheme can distinguish absent credentials from invalid credentials: absent credentials continue without a user or evidence, while every presented invalid credential fails closed. The JWT module implements this capability for its bearer scheme; custom handlers remain required-only unless they make the same guarantee.

Observe security lifecycle events without coupling to REST internals. Declared by SecurityEventsModule in dev.vertique:vertique-security-runtime, which AuthModule includes.

@Provides @IntoSet
static SecurityEventObserver siemForwarder(SiemClient siem) {
return new SecurityEventObserver() {
@Override
public Future<Void> onCredentialAccepted(CredentialAcceptedEvent event) {
return siem.send("auth.accepted", event.identity(), event.authentication());
}
@Override
public Future<Void> onCredentialRejected(CredentialRejectedEvent event) {
return siem.send("auth.rejected", event.attemptedMethod(), event.reasonCode());
}
};
}

Synchronous authorization for applications that can decide from claims without I/O. Declared in dev.vertique:vertique-security-core; AuthorizationDecision decide(AuthorizationRequest request) takes the request alone — identity and claims are reached through request.securityContext().

@Provides
AuthorizationPolicy myPolicy() {
return request -> request.securityContext()
.authorization()
.valuesOf(AuthorityKind.ROLE)
.contains("admin")
? AuthorizationDecision.permit("ADMIN_ROLE")
: AuthorizationDecision.deny("INSUFFICIENT_ROLE");
}

Feed the grants of contributed Vert.x AuthorizationProviders into the request’s AuthorizationClaims. The AuthorizationProvider multibinding declared by AuthModule is inert on its own; including VertxAuthorizationImportModule in the component installs the importer that identity resolution runs on every authenticated request, between claim mapping and SecurityContext binding. Anonymous requests, and applications without the module, skip the step entirely.

@Singleton
@Component(modules = {VertxModule.class, RestModule.class,
AuthModule.class, SecurityModule.class,
VertxAuthorizationImportModule.class,
AppModule.class, ResourceModule.class})
interface AppComponent { ... }
@Provides @IntoSet
static AuthorizationProvider entitlementProvider(EntitlementDirectory directory) {
// any io.vertx.ext.auth.authorization.AuthorizationProvider with a stable, unique id
return directory.asAuthorizationProvider();
}

Execution contract:

  • Sequential and deterministic. Providers run one at a time, in ascending getId() order, so the observable claim set never depends on registration order. Every provider id must be non-blank and unique across the set; a violation fails Dagger graph construction, not the first request.
  • Request-local user. Providers never see the caller’s Vert.x User. They share one request-local User built per import from deep copies of the caller’s principal and attributes.
  • All-or-nothing. Claims are merged only after every provider has succeeded. Any provider failure — a failed future, a synchronous throw, or a null future — fails the request with 503; no partially imported claim is ever observable.
  • Generic client detail, provider id server-side. The UnavailableException behind that 503 carries the fixed detail Authorization is temporarily unavailable, so the response never discloses which provider failed. The failing provider id and its cause are recorded instead in exactly one ERROR log event per failed import, emitted by the importer. An import that failed only because the Resilience runtime had closed at shutdown is logged at INFO instead.
  • Same-instance return on an empty import. When the import contributes no claim — every provider excluded, or providers ran but granted nothing mappable — the base AuthorizationClaims instance is carried through unchanged rather than copied.
  • Bounded invocation. Providers must not block the event loop. A provider whose future has not completed within security.authz.importTimeoutMs (default 5000) is treated as failed, through the application’s Resilience runtime, exactly like any other provider failure above: the request fails with the same 503 and generic detail, and the provider id is logged once at ERROR. A future the provider returns already complete is not timed. The bound applies to each provider separately, so an import across N providers can wait up to N × importTimeoutMs before failing, and a request that also has an action gate can then wait a further 2 × gateDeadlineMs. The timeout does not cancel the provider’s own work. See Authorization import deadline.

Mapping is fail-closed. Only these grants become claims:

Vert.x authorization Imported as
RoleBasedAuthorization with no resource AuthorityKind.ROLE
PermissionBasedAuthorization with no resource AuthorityKind.PERMISSION

Imported claims carry source = "vertx-provider:<providerId>" and empty issuer, audience, and attributes. Everything else — wildcard permissions, And/Or/Not composites, resource-scoped grants, and blank values — is dropped and logged once per (provider, authorization type) pair at WARN; the authorization value is never logged. The Vert.x jwt-claims provider bucket is always excluded: its scope→permission projection is lossy, and the JWT principal already reaches AuthorizationClaims with full kind fidelity through SecurityClaimMapper.

What the two modules put in the graph, and where each instance comes from:

Binding key Module Source
SecurityRuntime SecurityModule HolderBackedSecurityRuntime (context-holder-backed)
JaxRsSecurityContextFactory SecurityModule JaxRsSecurityContext::new
IdentityPipelineFactory AuthModule assembled from the security collaborators; @Singleton
IdentityResolutionMiddleware AuthModule identityPipelineFactory.restIdentityResolution()
SecurityPolicyEnforcer AuthModule identityPipelineFactory.policyEnforcer()
SecurityPolicyValidator AuthModule DefaultSecurityPolicyValidator
AuthEnforcementCapability AuthModule the install signal the route registrar checks before accepting restrictive annotations
CredentialRejectionReporter AuthModule DefaultCredentialRejectionReporter
ChannelIdentityManager AuthModule DefaultChannelIdentityManager
RequestOriginConfig AuthModule RequestOriginConfig.defaults()
Set<OperationHandlerContributor> AuthModule identity resolution, authorization, and action-gate authentication contributors
Set<Middleware> AuthModule OriginCaptureMiddleware

IdentityResolutionMiddleware and SecurityPolicyEnforcer also carry @Inject constructors for hand-wiring outside a Dagger graph, but the explicit AuthModule bindings above take precedence wherever both could apply — so a graph including AuthModule holds exactly one of each, obtained from the factory.

AuthModule includes ResilienceModule, so the graph provides the application’s single Resilience runtime and must bind a Vertx. The SecurityPolicyEnforcer and IdentityPipelineFactory constructors take that Resilience as their final parameter, and VertxAuthorizationImportModule includes ResilienceModule too. A hand-wired call site passes the runtime it owns, created once per application with Resilience.create(vertx) and not per enforcer.

Binding Default Override with
SecurityClaimMapper three-convention JWT mapper @Provides SecurityClaimMapper
AuthorizationDecisionPoint claims-based evaluation @Provides AuthorizationDecisionPoint
CredentialRejectionReporter assembles and emits CredentialRejectedEvent @Provides CredentialRejectionReporter
ChannelIdentityManager in-memory registry with expiry timers @Binds ChannelIdentityManager
RequestOriginConfig trust no proxy @Provides @Singleton RequestOriginConfig

@Path("/orders")
public class OrderResource {
@GET
@Path("/{id}")
@RolesAllowed("user")
public Future<Order> getOrder(
@PathParam("id") String id, dev.vertique.security.SecurityContext sc) {
// sc.identity().actor().id() — authenticated user id
// sc.authorization().valuesOf(AuthorityKind.SCOPE) — granted scopes
return orderService.findById(id);
}
@POST
@Authorized(scopes = "orders:write")
public Future<Response> createOrder(CreateOrderRequest req) { ... }
}

Either context type may be injected as a resource-method parameter:

public Future<Response> handle(dev.vertique.security.SecurityContext sc) { ... }
public Future<Response> handle(jakarta.ws.rs.core.SecurityContext sc) { ... }
Annotation Effect
@DenyAll Always 403; wins over every other annotation
@PermitAll No authorization handler installed
@RolesAllowed("a", "b") OR — any one listed role is sufficient
@Authorized(scopes = "write") The listed scope is required
@Authorized(scopes = {"a", "b"}) AND — all listed scopes required (matchAll defaults to true)
@Authorized(scopes = {"a", "b"}, matchAll = false) OR — any one listed scope is sufficient
@Authorized(scopes = {}) Authentication only — any authenticated caller
@RolesAllowed with @Authorized AND — the role check and the scope check must both pass
@RequiresAction("…") AND — the action gate must pass in addition to the role/scope gate

@Authorized on a class applies to every method; a method-level @Authorized overrides it.


RequestOriginConfig is a Dagger binding, not a config-file section. The default trusts no proxy, so clientIp always equals remoteIp and forwarded scheme and host headers are ignored.

@Provides @Singleton
RequestOriginConfig originConfig() {
return new RequestOriginConfig(
Set.of("10.0.0.0/8", "172.16.0.0/12"), // trustedProxyCidrs
16, // forwardedForCap
true, // trustForwardedScheme
true); // trustForwardedHost
}
Component Default Meaning
trustedProxyCidrs empty CIDR ranges whose members may set forwarded headers; empty trusts nothing
forwardedForCap 16 Maximum X-Forwarded-For entries; a longer chain is dropped entirely and flagged rejected. Must be >= 0; 0 disables the chain
trustForwardedScheme false Honour X-Forwarded-Proto — only when the direct peer is trusted
trustForwardedHost false Honour X-Forwarded-Host — only when the direct peer is trusted

Resolution rules:

  • X-Forwarded-For is always parsed and exposed for observability, trusted peer or not.
  • Each X-Forwarded-For entry must be an IP address literal. Hostnames and malformed addresses are dropped and counted in forwardedForRejectedCount; they are never looked up in DNS. Entries are parsed syntactically and never passed to the JDK resolver, so hex-only words (deadbeef), short forms (1234), dotted quads with an octet above 255 and IPv4 octets with leading zeros (010.1.1.1) are rejected, not resolved or reinterpreted.
  • clientIp uses the forwarded chain only when the direct peer matches a trusted CIDR; otherwise it is the direct peer address.
  • Forwarded scheme and host are honoured only when both the corresponding flag is set and the direct peer is trusted.

A non-empty trustedProxyCidrs is what makes forwarded headers trustworthy. Setting trustForwardedScheme or trustForwardedHost without it changes nothing.

Authorization gate deadline (AuthorizationGateConfig)

Section titled “Authorization gate deadline (AuthorizationGateConfig)”

AuthorizationGateConfig bounds every SecurityPolicyEnforcer role/scope and action gate future — the one decide awaits and the one each REST and WebSocket handler awaits. By default SecurityPolicyEnforcer uses AuthorizationGateConfig.DEFAULT_GATE_DEADLINE_MS (5000) — no application wiring is required to get this default. To config-drive it instead, install AuthorizationGateConfigModule alongside AuthModule:

@Component(modules = {..., AuthModule.class, AuthorizationGateConfigModule.class})
public interface AppComponent { ... }
security:
authz:
gateDeadlineMs: 5000
Component Config key Default Meaning
gateDeadlineMs security.authz.gateDeadlineMs 5000 Milliseconds bounding every role/scope and action gate future; must be > 0

This is one shared value for every transport that reuses SecurityPolicyEnforcer — REST (AuthorizationContributor), WebSocket (WebSocketMount), and MCP (McpPolicyEnforcer) all observe the same configured deadline; there is no per-transport override. A gate that exceeds the deadline fails closed with AuthzReasonCodes.INTERNAL_AUTHZ_ERROR, exactly like any other gate contract violation (see Request-time outcomes).

An application may instead bind AuthorizationGateConfig programmatically (a @Provides method returning a constructed instance) rather than installing AuthorizationGateConfigModule, if it needs the value from a source other than the standard config file.

Authorization import deadline (AuthorizationImportConfig)

Section titled “Authorization import deadline (AuthorizationImportConfig)”

AuthorizationImportConfig bounds each Vert.x AuthorizationProvider invocation made by the opt-in VertxAuthorizationImporter. VertxAuthorizationImportModule uses AuthorizationImportConfig.DEFAULT_IMPORT_TIMEOUT_MS (5000) with no further wiring. To config-drive it, install AuthorizationImportConfigModule alongside it:

@Component(modules = {..., AuthModule.class, VertxAuthorizationImportModule.class,
AuthorizationImportConfigModule.class})
public interface AppComponent { ... }
security:
authz:
importTimeoutMs: 5000
Component Config key Default Meaning
importTimeoutMs security.authz.importTimeoutMs 5000 Milliseconds bounding each provider invocation; must be > 0

Setting the key without installing AuthorizationImportConfigModule has no effect, as for gateDeadlineMs. Both keys share the security.authz section; each record reads only its own. A provider that exceeds the bound fails the import with the generic 503 described under Authorization import.


Failures, Constraints, and Common Mistakes

Section titled “Failures, Constraints, and Common Mistakes”
Failure Cause
RestConfigurationException Any shape in the fail-closed matrix: a multi-scheme AND-set, scopes on an OR alternative, scopes from both @Authorized and @SecurityRequirement, or duplicate schemeName()
RestConfigurationException @RolesAllowed / @Authorized / @DenyAll on an operation with no declared security requirement — the handler would never run
RestConfigurationException A declared security requirement with no matching SecuritySchemeHandler registered
RestConfigurationException @PermitAll on an operation that also declares a security requirement — conflicting intent
IllegalStateException Two SecurityIdentityResolvers share the same (priority, id) pair. Raised while the Dagger graph is constructed, not on first request
IllegalStateException An action-only @RequiresAction route with no RouteAuthHandler registered, or with more than one — JAX-RS has no per-route scheme selector, so the framework fails rather than guess
IllegalStateException A SecurityPolicy.Constrained with both empty roles and empty scopes. Raised at handler creation, not on first request

There is no warn-only mode. Every validation failure stops startup.

Status Reason code Situation
401 — Credential verification failed; the authentication handler reported and failed the request
401 AUTHENTICATION_REQUIRED No SecurityContext bound, or an anonymous actor on an AuthenticatedOnly or Constrained route
403 DENY_ALL @DenyAll
403 the decision’s own code The decision point denied
403 INTERNAL_AUTHZ_ERROR The decision point or Authorizer threw synchronously, returned a null future, or resolved to a null decision — a contract violation, fail-closed
503 INTERNAL_AUTHZ_ERROR or AUTHORITY_RESOLUTION_FAILED The decision point or Authorizer could not answer: it returned a failed future (with any exception), exceeded the configured gate deadline, the Resilience runtime had closed, or it returned a decision whose reason code is INTERNAL_AUTHZ_ERROR (an engine-internal error) or AUTHORITY_RESOLUTION_FAILED (a Mode-2 principal-authority resolution failed or timed out) — fail-closed. The event keeps the decision’s own reason code. The problem detail is the generic Authorization is temporarily unavailable, with a Retry-After of the gate deadline rounded up to whole seconds; the cause is logged, never returned
503 — A provider failed during the opt-in Vert.x authorization import — fail-closed: the SecurityContext is never bound and no partially imported claim is observable. The problem detail is the generic Authorization is temporarily unavailable; the failing provider id is logged, never returned
— PERMITTED Both gates passed

A decision future that fails, as opposed to one that denies, means the gate could not answer: the request fails closed as unavailable — 503, INTERNAL_AUTHZ_ERROR and one event — and is distinct from a 403 denial. The cause is logged server-side and is not handed to the error pipeline, so a policy client’s own exception, whatever its type or message, never decides the response status or appears in the response body. The 503 carries Retry-After (the gate deadline in whole seconds, at least one). Load balancers and meshes that retry on another upstream or eject a host after consecutive 5xx will act on it, and a WebSocket client that reconnects on 5xx re-enters authorization each time; a failing or hung policy point therefore becomes visible to them as an outage, which is the point of not answering 403, and rate limiting belongs ahead of authorization to bound how often the policy point is asked. A deterministic fault on one request (a policy client rejecting a crafted value) is also a 503 for that request. Every path that reaches authorization emits exactly one AuthorizationDecisionEvent; the credential-failure 401 and the import-failure 503 short-circuit the request before authorization runs, so no decision event is emitted for them.

With a @RequiresAction gate, the role/scope gate is evaluated first and the action gate only if it permits. The single emitted decision carries the first failing predicate as its top-level reason code, plus rolesSatisfied, actionSatisfied, and actionEvaluated in safeAttributes.

  • Emitting from a custom AuthorizationDecisionPoint. The enforcement layer already emits one event per attempt; a decision point that emits produces duplicates. Return the decision only.
  • Expecting CredentialAcceptedEvent for anonymous traffic. It is emitted only when evidence is non-empty. An anonymous request still gets a bound SecurityContext, just no event. The event is also skipped, with a warning, when no CorrelationContext is bound.
  • Expecting identity resolution to report rejections. CredentialRejectedEvent comes from the failing authentication handler before ctx.fail(...); identity resolution never runs on that path.
  • Contributing a Vert.x AuthorizationProvider without including VertxAuthorizationImportModule. The multibinding set is inert on its own — no provider is ever consulted until the opt-in import module is wired into the component.
  • Expecting resource-scoped, wildcard, or composite Vert.x authorizations to import. The import maps only resource-free role and permission grants; everything else is dropped fail-closed with a throttled WARN.
  • Trusting forwarded headers without trustedProxyCidrs. trustForwardedScheme and trustForwardedHost do nothing while the trusted-proxy set is empty.
  • Reading roles straight off AuthorizationClaims for a JAX-RS check. isUserInRole deliberately returns false for an anonymous actor even if claims are present; hand-rolled checks lose that guard.
  • Including AuthModule without SecurityModule. SecurityModule owns the SecurityRuntime binding that every enforcement path reads.

Artifact Why
dev.vertique:vertique-rest-core Middleware, OperationHandlerContributor, SecurityPolicy, EffectiveSecurityPolicy, SecuritySchemeHandler, RouteAuthHandler, SecurityRuntime, RequestContextLifecycle
dev.vertique:vertique-security-core The typed identity model, AuthorizationClaims, AuthorizationPolicy, Authorizer, ChannelIdentityManager, SecurityIdentityResolver
dev.vertique:vertique-security-runtime SecurityEventEmitter, SecurityEventsModule, IdentitySnapshotCapture
dev.vertique:vertique-context ContextHolder scopes and the service-dispatch context encoder/decoder seam
dev.vertique:vertique-logging MDC key binding for userId, clientId, and authMethod
io.vertx:vertx-auth-common AuthenticationHandler, User, AuthorizationProvider
jakarta.ws.rs:jakarta.ws.rs-api The jakarta.ws.rs.core.SecurityContext bridge and the Jakarta security annotations
com.google.dagger:dagger Module wiring and multibindings
org.projectlombok:lombok Compile-time only