REST security
View MarkdownStatus: Stable Package:
dev.vertique.rest.securityArtifact:vertique-rest-securityDepends 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.
When To Use It
Section titled “When To Use It”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 |
Core Concepts
Section titled “Core Concepts”What runs, in what order
Section titled “What runs, in what order”Every request passes three separately-owned stages. Only the last two belong to this module.
- Authentication — an
AuthenticationHandlerinstalled from the operation’s security scheme setsctx.user()and appendsAuthenticationEvidence. On failure it callsCredentialRejectionReporter.report(...)and thenctx.fail(...), which short-circuits everything below. - Identity resolution (priority 80) — runs the
SecurityIdentityResolverchain, maps the Vert.xUserprincipal toAuthorizationClaims, and — only when the opt-inVertxAuthorizationImportModuleis included and the request is authenticated — imports the grants of every contributed Vert.xAuthorizationProviderinto those claims (see Vert.x authorization import); then assembles theSecurityContext, binds it for the rest of the request, and emitsCredentialAcceptedEvent.IdentityResolutionMiddleware.handle(...)binds the REST invocation origin by default; another transport reusing the same resolver must usehandlerFor(InvocationOrigin)to bind its explicit boundary identity instead. - Authorization (priority 100) — evaluates the effective
SecurityPolicyand any@RequiresActiongate, and emits exactly oneAuthorizationDecisionEvent.
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.
The resolved SecurityContext
Section titled “The resolved SecurityContext”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
SecurityRequirementSetis an OR — satisfying any one set authenticates the request; - every
SecurityRequirementinside 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-corepublic 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).
Fail-closed startup matrix
Section titled “Fail-closed startup matrix”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.
Security events
Section titled “Security events”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.
Getting Started
Section titled “Getting Started”@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.
Key Classes
Section titled “Key Classes”RestAuthenticationEvidence
Section titled “RestAuthenticationEvidence”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())); // safeAttributesRestAuthenticationEvidence.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.
CredentialRejectionReporter
Section titled “CredentialRejectionReporter”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.
SecurityClaimMapper
Section titled “SecurityClaimMapper”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:
@ProvidesSecurityClaimMapper 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).
AuthorizationDecisionPoint
Section titled “AuthorizationDecisionPoint”Async authorization SPI. Implementations receive a fully-populated AuthorizationRequest and return
Future<AuthorizationDecision>.
@FunctionalInterfacepublic interface AuthorizationDecisionPoint { Future<AuthorizationDecision> decide(AuthorizationRequest request);}@ProvidesAuthorizationDecisionPoint 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.
SecurityPolicyEnforcer
Section titled “SecurityPolicyEnforcer”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:
- an application-provided
AuthorizationDecisionPoint; - an application-provided sync
AuthorizationPolicy, wrapped asSyncPolicyDecisionPoint; - the built-in decision point, which evaluates roles, scopes, and permissions from
AuthorizationClaimsby delegating toClaimAuthorizationPolicyinvertique-security-runtime. ItsrequiredRoles,requiredScopesandrequireAllScopescontext 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.
IdentityPipelineFactory
Section titled “IdentityPipelineFactory”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.
IdentityPipelineOptions
Section titled “IdentityPipelineOptions”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();JaxRsSecurityContext
Section titled “JaxRsSecurityContext”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.
ChannelIdentityManager
Section titled “ChannelIdentityManager”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.
Extension Points
Section titled “Extension Points”SecurityIdentityResolver (multibinding)
Section titled “SecurityIdentityResolver (multibinding)”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 @IntoSetstatic 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())); } };}SecuritySchemeHandler (multibinding)
Section titled “SecuritySchemeHandler (multibinding)”Contribute the authentication handler for a named OpenAPI security scheme.
@Provides @IntoSetSecuritySchemeHandler 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.
RouteAuthHandler (multibinding)
Section titled “RouteAuthHandler (multibinding)”Route-level authentication for transports with no OpenAPI security scheme — WebSocket endpoints and
action-only @RequiresAction routes.
@Provides @IntoSetRouteAuthHandler 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.
SecurityEventObserver (multibinding)
Section titled “SecurityEventObserver (multibinding)”Observe security lifecycle events without coupling to REST internals. Declared by
SecurityEventsModule in dev.vertique:vertique-security-runtime, which AuthModule includes.
@Provides @IntoSetstatic 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()); } };}AuthorizationPolicy (optional binding)
Section titled “AuthorizationPolicy (optional binding)”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().
@ProvidesAuthorizationPolicy myPolicy() { return request -> request.securityContext() .authorization() .valuesOf(AuthorityKind.ROLE) .contains("admin") ? AuthorizationDecision.permit("ADMIN_ROLE") : AuthorizationDecision.deny("INSUFFICIENT_ROLE");}Vert.x authorization import (opt-in)
Section titled “Vert.x authorization import (opt-in)”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 @IntoSetstatic 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-localUserbuilt 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
nullfuture — fails the request with 503; no partially imported claim is ever observable. - Generic client detail, provider id server-side. The
UnavailableExceptionbehind that 503 carries the fixed detailAuthorization 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 theResilienceruntime 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
AuthorizationClaimsinstance 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(default5000) is treated as failed, through the application’sResilienceruntime, 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 acrossNproviders can wait up toN × importTimeoutMsbefore failing, and a request that also has an action gate can then wait a further2 × 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.
Module Dagger Bindings
Section titled “Module Dagger Bindings”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.
Replaceable bindings
Section titled “Replaceable bindings”| 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 |
JAX-RS Integration
Section titled “JAX-RS Integration”@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 semantics
Section titled “Annotation semantics”| 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.
Configuration
Section titled “Configuration”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 @SingletonRequestOriginConfig 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-Foris always parsed and exposed for observability, trusted peer or not.- Each
X-Forwarded-Forentry must be an IP address literal. Hostnames and malformed addresses are dropped and counted inforwardedForRejectedCount; 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 above255and IPv4 octets with leading zeros (010.1.1.1) are rejected, not resolved or reinterpreted. clientIpuses 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”Startup failures
Section titled “Startup failures”| 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.
Request-time outcomes
Section titled “Request-time outcomes”| 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.
Common mistakes
Section titled “Common mistakes”- 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
CredentialAcceptedEventfor anonymous traffic. It is emitted only when evidence is non-empty. An anonymous request still gets a boundSecurityContext, just no event. The event is also skipped, with a warning, when noCorrelationContextis bound. - Expecting identity resolution to report rejections.
CredentialRejectedEventcomes from the failing authentication handler beforectx.fail(...); identity resolution never runs on that path. - Contributing a Vert.x
AuthorizationProviderwithout includingVertxAuthorizationImportModule. 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.trustForwardedSchemeandtrustForwardedHostdo nothing while the trusted-proxy set is empty. - Reading roles straight off
AuthorizationClaimsfor a JAX-RS check.isUserInRoledeliberately returnsfalsefor an anonymous actor even if claims are present; hand-rolled checks lose that guard. - Including
AuthModulewithoutSecurityModule.SecurityModuleowns theSecurityRuntimebinding that every enforcement path reads.
Dependencies
Section titled “Dependencies”| 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 |