Skip to content

REST WebSocket

View Markdown

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

vertique-rest-websocket is the annotation-driven WebSocket transport for Vertique. You write a plain class annotated with @WebSocketEndpoint, add lifecycle methods (@OnOpen, @OnMessage, @OnClose, @OnError), and contribute it to a Dagger multibinding. The module mounts a dedicated Vert.x sub-router ahead of the JAX-RS router, authenticates and authorizes the HTTP upgrade request using the same security annotations JAX-RS resources use, and propagates the request’s ambient context — security context, correlation, MDC — into the connection for its whole lifetime.

It is not a JAX-RS transport: WebSocket endpoints are not @Path resources, they are not described in the generated OpenAPI document, and there is no per-message authorization. Every security decision is made once, at upgrade time, before the 101 response is written.


Install this module when the application serves WebSocket connections and wants Vertique’s authentication, typed message handling, and context propagation on them rather than hand-rolling a ServerWebSocket handler.

You also want Also install
Bearer-token authentication on the upgrade dev.vertique:vertique-rest-auth-jwt (or another module contributing a RouteAuthHandler)
Role/scope enforcement and a resolved SecurityContext dev.vertique:vertique-rest-security (AuthModule + SecurityModule)
@RequiresAction action gates on endpoints dev.vertique:vertique-security-runtime (SecurityAuthzModule)
A reusable @RequiresPolicy on endpoints dev.vertique:vertique-security-core (policy types); dev.vertique:vertique-rest-security for a role/scope policy (enforcement); add vertique-security-runtime when the policy carries an action
Bean Validation on typed messages dev.vertique:vertique-validation (ValidationModule)
Canonicalization/sanitization of inbound values dev.vertique:vertique-sanitization (SanitizationModule)

Without a security module the endpoints still work, unauthenticated. That is a supported configuration for local development and for genuinely public sockets.


A WebSocket endpoint’s route runs four handlers in a fixed order before the socket exists:

  1. Authentication — installed only when the endpoint’s policy is @RolesAllowed/@Authorized (or when a class-level @RequiresAction is present, or when a class-level @RequiresPolicy resolves to a role, scope or authenticated-caller requirement, or carries an action). It sets the Vert.x user and appends authentication evidence.
  2. Identity resolution — resolves the framework SecurityContext from that evidence.
  3. Authorization — evaluates the role/scope policy AND, when present, the @RequiresAction action gate. Both must pass.
  4. Upgrade — only now is HttpServerRequest.toWebSocket() called.

A rejected caller therefore never reaches step 4 and never sees a 101 response. The client observes an ordinary HTTP failure status on the handshake.

When AuthModule is installed, steps 2–3 are supplied by the security-owned dev.vertique.rest.security.IdentityPipelineFactory — the single assembly point every transport (REST, MCP, WebSocket) reads identity resolution and authorization from. The handler this module installs is assembled for invocation origin websocket (DispatchBoundary.WEBSOCKET) with identity-snapshot capture always off, regardless of whether the application binds a capture — a channel upgrade establishes a long-lived identity, not the per-request lifecycle that capture is defined for. AuthorizationDecisionEvents and audit attributes recorded for a WebSocket upgrade therefore carry origin kind websocket, distinguishing them from REST’s rest.

Fail-closed registration when the pipeline is absent. Without AuthModule (no IdentityPipelineFactory bound), registering an endpoint whose security policy is restrictive (@DenyAll, @RolesAllowed, @Authorized, or a @RequiresPolicy resolving to one of them) or that declares a class-level @RequiresAction fails startup with one aggregated IllegalStateException naming every offending endpoint class and its policy — see Startup failures. Unannotated endpoints are unaffected and keep serving unauthenticated.

Authorization runs once, at upgrade. @RequiresAction and @RequiresPolicy are accepted at class level only; placing either on @OnOpen, @OnMessage, @OnClose, or @OnError fails startup rather than being silently ignored. If a connection’s privileges must change mid-stream, refresh the channel identity (see Identity refresh) — do not expect the framework to re-check a gate per frame.

A class-level @RequiresPolicy names a reusable access policy (an interface extending AccessPolicy whose annotations declare the requirements) and resolves at startup to the same role, scope, public/deny and action gate the inline annotations produce. It decides whether the upgrade is admitted and nothing else.

@RequiresPolicy(EditorPolicy.class)
@WebSocketEndpoint("/ws/documents/{id}")
public class DocumentEndpoint {
private final DocumentService documents;
@OnMessage
Future<Void> onMessage(WebSocketSession session, DocumentEdit edit) {
// The upgrade was admitted by EditorPolicy; the service re-checks its own policy per call.
return documents.apply(edit);
}
}

Admission and service access are separate decisions:

  • A permitted upgrade grants no standing permission. A message that calls a policy-protected service is checked again at that service, with the caller’s identity at that moment; a refused call has no effect.
  • Handler code is not intercepted. Arbitrary code in your @OnMessage method and ordinary Java calls on service implementations are not enforced automatically. Protect business work with a policy on the service and call it through the generated or proxy service client.

The socket is paused the moment the handshake succeeds and is resumed only after your @OnOpen method has completed successfully. A message sent by the peer immediately after connecting is queued, not dropped, and is delivered after @OnOpen. If @OnOpen throws or returns a failed future, the socket is never resumed and is closed with code 1011.

Everything bound to the request’s ambient context at upgrade time — the SecurityContext, CorrelationContext, MDC keys, and any other typed context value — is captured and re-bound onto the connection for its lifetime. Read it inside any lifecycle callback:

@OnMessage
void onMessage(WebSocketSession session, SecurityContext ctx, ChatMessage msg) {
ctx.identity().subject().ifPresent(p -> log.info("message from {}", p));
}

WebSocketSession deliberately has no securityContext() accessor and no way to close the live scope. Read the context one of three ways:

Access Notes
A dev.vertique.security.SecurityContext lifecycle-method parameter Simplest; null when no security module is installed
An injected dev.vertique.rest.core.security.SecurityRuntime, then current() SecurityRuntime is an interface bound only when SecurityModule is installed
dev.vertique.context.ContextValues.current(SecurityContext.class) Static; returns Optional<SecurityContext>

All three re-read the ambient value on every invocation, so they observe an identity refresh.


@WebSocketEndpoint("/ws/chat/{roomId}")
@RolesAllowed("chat:connect")
@Singleton
public class ChatEndpoint {
private final ChatRoomRegistry registry;
@Inject
ChatEndpoint(ChatRoomRegistry registry) {
this.registry = registry;
}
@OnOpen
void onOpen(WebSocketSession session, @PathParam("roomId") String roomId) {
registry.join(roomId, session);
}
@OnMessage
Future<Void> onMessage(WebSocketSession session, ChatMessage msg) {
String roomId = session.pathParams().get("roomId");
return registry.broadcast(roomId, msg);
}
@OnClose
void onClose(WebSocketSession session) {
registry.leave(session.id());
}
@OnError
void onError(WebSocketSession session, Throwable error) {
log.error("WebSocket error on session {}", session.id(), error);
}
}

Contribute the endpoint instance:

@Module
public abstract class ChatModule {
@Provides
@IntoSet
@WebSocketEndpoints
static Object chatEndpoint(ChatEndpoint endpoint) {
return endpoint;
}
}

Wire the component:

@Singleton
@Component(modules = {VertxModule.class, ConfigParsingModule.class,
RestModule.class, JwtAuthModule.class,
WebSocketModule.class, ChatModule.class, AppModule.class})
interface AppComponent {
HttpVerticle httpVerticle();
}

WebSocketModule lives in dev.vertique.rest.websocket.dagger — not the module’s base package — and injects a ConfigParser, so ConfigParsingModule (from dev.vertique:vertique-config-core) must be in the graph. JwtAuthModule supplies the authentication handler and transitively includes AuthModule + SecurityModule; drop it for an unauthenticated socket.

When no endpoint is contributed the module registers no mount at all, so including it in a component that has no WebSocket endpoints costs nothing.


Class-level annotation marking a WebSocket endpoint.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface WebSocketEndpoint {
String value();
String authScheme() default "";
}
Attribute Default Meaning
value (required) Path template. {name} placeholders each match exactly one path segment.
authScheme "" Selects which registered RouteAuthHandler authenticates the upgrade, by schemeName(). Empty means auto-select: exactly one handler must be registered, otherwise registration fails.

The path template is relative to websocket.basePath. Combine the annotation with the JAX-RS security annotations (@RolesAllowed, @PermitAll, @DenyAll, @Authorized) and, optionally, @RequiresAction — all at class level — or replace them with a single class-level @RequiresPolicy, which cannot be mixed with them.

@OnOpen, @OnMessage, @OnClose, and @OnError are method-level markers with no attributes. At most one method per annotation per endpoint; a second one fails startup. All four are optional.

Annotation Fires when
@OnOpen The handshake succeeded and the session context is bound. Runs before any frame is delivered.
@OnMessage A complete text or binary message arrives.
@OnClose The connection closed, from either side.
@OnError A socket-level error occurs, an inbound message fails to deserialize or validate, or another lifecycle method throws or returns a failed future.

Return type must be void or Future<Void>; anything else fails startup. Only @OnOpen and @OnClose have their futures awaited — @OnOpen’s gates resume(), @OnClose’s gates resource release. A failed future from @OnMessage or @OnError is logged and dispatched to @OnError.

Parameters are resolved by type and annotation, in this order:

Parameter shape Bound to
WebSocketSession (or a supertype) The current session
Throwable (or a subtype) The error — only on @OnError; otherwise left null
dev.vertique.security.SecurityContext SecurityRuntime.current() at invocation time; null when no security module is installed
@PathParam("name") T The extracted path segment, converted to T
any other parameter The deserialized message — only on @OnMessage

There is no query-parameter, header, or body-annotation injection. Read the upgrade request’s query string and headers from session.queryParams() and session.headers().

@PathParam targets support String, int/Integer, and long/Long. Any other declared type throws IllegalArgumentException when the callback is invoked. A @PathParam name that is not a placeholder in the endpoint’s path template fails startup.

The @OnMessage message parameter is the first parameter that is not a WebSocketSession, a Throwable, a SecurityContext, or @PathParam-annotated. Its declared type selects the wire handling. Exactly one such parameter is allowed: a second payload-eligible parameter is rejected at startup with a ConfigurationException naming the method. Put the sanitize/canonicalize chain on the message parameter or on the method:

Declared type Handling
String Text frames delivered verbatim, no JSON decoding
io.vertx.core.buffer.Buffer The endpoint listens for binary messages instead of text
anything else Text frames decoded from JSON into that type

An endpoint listens for either text or binary messages, never both — the Buffer parameter is what switches it. With no message parameter (or no @OnMessage at all) the endpoint receives nothing.

JSON encoding and decoding — inbound typed messages and session.send(Object) — use the process JSON codec’s mapper (VertiqueJson.mapper()), the same mapper every other unmanaged framework path runs on: whatever profile json.systemProfile selects applies to WebSocket payloads too.

WebSocket message binding has no request-validation gate in front of it, so it is binder-authoritative: under an enum-lenient system profile (the reserved vertique profile or any custom profile that enables READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE), an unrecognized enum string binds to the field’s @JsonEnumDefaultValue instead of being rejected — the same enum leniency every process-codec path exposes. Process-codec paths also accept JSON comments under the default system profile, exactly as they do today; a comment-strict custom profile turns that off.

A message that fails to deserialize or fails Bean Validation is dropped: @OnError is invoked with the exception when declared, and @OnMessage is not called. The connection stays open.

The handle passed to lifecycle methods. Instances are scoped to one connection and are not thread-safe — touch them only on the connection’s event loop.

public interface WebSocketSession {
String id(); // random UUID, stable for the connection
ServerWebSocket raw(); // underlying Vert.x socket
String path(); // request path from the upgrade
Map<String, String> pathParams(); // immutable
MultiMap queryParams(); // from the upgrade request URI
MultiMap headers(); // from the upgrade request
Map<String, Object> attributes(); // mutable, application-owned
Future<Void> send(Object message); // serialized to a JSON text frame
Future<Void> sendText(String text);
Future<Void> sendBinary(Buffer data);
Future<Void> close(); // close code 1000
Future<Void> close(short statusCode, String reason);
boolean isOpen();
}

id() is also the channel id used by ChannelIdentityManager, so it is what you pass to refreshIdentity.

Read from the websocket section of the application config.

Key Type Default Description
websocket.basePath String "/*" Path at which the WebSocket sub-router is mounted. Must start with / and end with /*.
{
"websocket": {
"basePath": "/ws/*"
}
}

This module sets no frame-size or message-size limits of its own. Those are Vert.x server options — configure them on the HTTP server, not here.

Injectable factory for building additional mounts programmatically, e.g. to mount a second endpoint group at a different path or ahead of another RouterMount.

public WebSocketMount create(String mountPath, Set<Object> endpoints);
public WebSocketMount create(String mountPath, Set<Object> endpoints, int priority);

The default mount created from @WebSocketEndpoints uses priority -100, which places it ahead of the JAX-RS mount so WebSocket paths are matched before catch-all REST routes. Contribute a hand-built mount to the Set<RouterMount> multibinding:

@Provides
@IntoSet
static RouterMount adminSocketMount(WebSocketMount.Factory factory, AdminEndpoint endpoint) {
return factory.create("/admin/ws/*", Set.of(endpoint), -200);
}

A client connecting to a Vertique WebSocket endpoint sees the server’s refusal through one of several raw Vert.x/Netty exception shapes depending on timing. Normalize them instead of pattern-matching third-party types:

public abstract sealed class WebSocketUpgradeException extends TechnicalException
permits WebSocketUpgradeRejected, WebSocketUpgradeTransportFailure {}
public final class WebSocketUpgradeRejected extends WebSocketUpgradeException {
public int status(); // e.g. 401, 403, 404, 503
}
public final class WebSocketUpgradeTransportFailure extends WebSocketUpgradeException {}
public final class WebSocketUpgradeExceptions {
public static WebSocketUpgradeException translate(Throwable err);
}

translate returns a WebSocketUpgradeException unchanged, preserves the structured status of a Vert.x UpgradeRejectedException, otherwise scans the message for a standalone 1xx–5xx token and returns WebSocketUpgradeRejected when one is found. With no recoverable status it returns WebSocketUpgradeTransportFailure — typically “the server closed the socket mid-handshake”, which is observable but says nothing about why. Retry before concluding anything from it.

client.connect(options).onFailure(err -> {
WebSocketUpgradeException upgrade = WebSocketUpgradeExceptions.translate(err);
if (upgrade instanceof WebSocketUpgradeRejected rejected && rejected.status() == 401) {
refreshTokenAndReconnect();
}
});

These types live in dev.vertique.rest.websocket.transport and describe the client side of a handshake. Nothing on the server path throws them.


The only registration surface. Contribute each endpoint instance as Object into the @WebSocketEndpoints-qualified set; Dagger injects the endpoint’s own dependencies normally.

@Provides
@IntoSet
@WebSocketEndpoints
static Object chatEndpoint(ChatEndpoint endpoint) {
return endpoint;
}

The qualifier is dev.vertique.rest.websocket.dagger.WebSocketEndpoints. Endpoints are scanned and validated once, at router construction — a structural violation fails startup, not the first connection.

RouteAuthHandler (consumed, not declared here)

Section titled “RouteAuthHandler (consumed, not declared here)”

The upgrade’s authentication handler comes from the Set<RouteAuthHandler> multibinding declared by dev.vertique:vertique-rest-security. dev.vertique:vertique-rest-auth-jwt contributes the bearer handler; contribute your own to authenticate upgrades with a different scheme, then name it with @WebSocketEndpoint(authScheme = "...") when more than one is registered.


WebSocket endpoints are not JAX-RS resources, but they reuse two annotation families so an application declares security the same way on both transports:

Annotation Placement Effect at upgrade
@PermitAll class No authentication handler, no authorization gate
@DenyAll class Every upgrade is refused with 403
@RolesAllowed({...}) class Authenticate, then require any one of the listed roles
@Authorized(scopes = {...}) class Authenticate, then enforce the declared scopes
@RequiresAction("...") class Authenticate, then evaluate the action gate — AND-composed with the above
@RequiresPolicy(Policy.class) class Resolves to the policy’s role/scope/public/deny predicates and optional action gate, then behaves as the matching rows above; not combinable with the inline annotations
@PathParam("name") lifecycle method parameter Binds a path-template placeholder
@ValidateWith(groups = {...}) @OnMessage method Selects Bean Validation groups

Method-level security annotations (including @RequiresPolicy) on lifecycle methods fail startup — the endpoint class is the only site for the security policy above. Input canonicalization and sanitization policies are resolved separately, and do read method- and parameter-level declarations — see Validation and Input Processing.


Both features are optional bindings. When the providing module is absent the step is skipped silently — nothing fails.

Feature Install Applies to
Bean Validation ValidationModule The deserialized @OnMessage payload
Canonicalization / sanitization SanitizationModule The @OnMessage text payload and @PathParam string values; binary (Buffer) payloads are never processed

Canonicalizers and sanitizers see the value’s provenance in their InputValueContext: @OnMessage values — both a raw String payload and the decoded intermediate of a typed message — report InputLocation.PAYLOAD, and @PathParam values report InputLocation.PATH. Message values reported BODY before PAYLOAD existed: a custom processor that branches on InputLocation.BODY must also handle PAYLOAD to keep covering messages.

Bean Validation runs after deserialization (and after sanitization when both are installed). A violation raises BeanValidationException, which is routed to @OnError; the message is discarded.

public record CreateRoomMessage(
@NotBlank @Size(max = 64) String name,
@Min(1) @Max(100) int maxParticipants
) {}
@OnMessage
@ValidateWith(groups = {Default.class, StrictInput.class})
@Canonicalize(NfkcCanonicalize.class)
@Sanitize(StripControlCharsSanitize.class)
Future<Void> onMessage(WebSocketSession session, CreateRoomMessage msg) {
return session.send(new RoomCreated(msg.name()));
}
@OnError
void onError(WebSocketSession session, Throwable error) {
if (error instanceof BeanValidationException ex) {
session.send(new ErrorResponse(ex.violations()));
}
}

Policy resolution: class, then method, then parameter. @Canonicalize/@Sanitize are resolved the same way here as on a JAX-RS resource. A declaration on the endpoint class applies to every lifecycle method; a declaration on a lifecycle method replaces the class-level chain for that method; a declaration on a single parameter replaces the method’s chain for that one argument. Each level is read across the endpoint’s whole type hierarchy — a declaration on an implemented interface’s method, on a superclass, or behind a composed annotation counts as a declaration at that level — and the nearest declaration wins. Everything is resolved once per endpoint at registration, so no policy annotation is inspected while a message is being handled.

An override may replace but never remove an inherited policy. @SkipCanonicalization/ @SkipSanitization opt an element out of the chain it would otherwise inherit from the level above: a skip on a method exempts that method from the class-level chain, and a skip on a parameter exempts that argument from the method’s chain. Declaring a skip and the matching @Canonicalize/@Sanitize on the same element — including one inherited from an interface or superclass and the other on the override — is a contradiction and fails startup (see Startup failures). Remove one of the two, or declare the replacement chain instead of a skip.

A parameter-level @SkipCanonicalization/@SkipSanitization now takes effect. Earlier releases resolved WebSocket policies from the lifecycle method alone, so a skip on a parameter was silently inert and the value was processed anyway; today it removes processing for that argument only.

@PathParam and message values each use their own parameter’s policies. A path parameter is processed with the chain resolved for that parameter — its own declaration, else the method’s, else the class’s — so two placeholders bound by one method can be normalized differently, and @OnOpen and @OnMessage can each declare their own chain for the same placeholder. The message payload is processed with the chain resolved for the message parameter. Binary payloads are the exception: a Buffer message parameter is delivered to @OnMessage exactly as it arrived, because canonicalization and sanitization operate on string values.

Composed policy annotations are honored. A custom annotation meta-annotated with @Canonicalize/@Sanitize — the usual way to name a reusable chain — declares that chain on a class, a lifecycle method, or a parameter exactly as the bare annotation does, matching REST. The startup gate reads the very policies the message path later applies, so what fails the build and what runs on a message always agree.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Sanitize(StripAllHtmlSanitizer.class)
public @interface SafeText {}
@OnMessage
@SafeText // identical to @Sanitize(StripAllHtmlSanitizer.class)
void onMessage(WebSocketSession session, String text) { }

Renamed message fields are covered. A field-level policy is declared on a Java property, while an incoming message is keyed by whatever Jackson publishes — @JsonProperty("user_name"), a naming strategy, or a @JsonAlias. Messages are bound through the process JSON codec’s VertiqueJson.mapper(), and that mapper’s own property introspection is what maps each wire key back onto the Java property whose policies apply (JacksonFieldNameResolver, from dev.vertique:vertique-json, implementing the dev.vertique.core.sanitization.InputFieldNameResolver contract). A @Sanitize on a renamed field therefore runs exactly as it would on an unrenamed one, with no extra declaration. The projection is computed once per endpoint set when the registrar is built (the EDGE startup phase) from the mapper installed at that moment, while each frame binds through the mapper current at request time. A later same-id swap of the process codec that changes property naming would leave the projection stale — a renamed field’s policy could then silently not run — so such a swap must keep the replaced mapper’s naming; the framework never performs one after startup.

public record ProfileMessage(
@JsonProperty("display_name") @Sanitize(StripAllHtmlSanitizer.class) String displayName
) {}
// {"display_name": "<b>ada</b>"} -> displayName == "ada"

Each declared message type’s projection is composed at endpoint registration, not on the message path. The engine decides which types get composed, not the registrar: registration hands each declared message type to InputObjectProcessor.precomputeFieldNameResolution, which prepares the resolver for every owner type its own descent may consult for that message. The postcondition is that no statically knowable owner is left to introspect on the event loop — an array message type reduces to its component and a scalar such as the default String simply has nothing beneath it, both by the engine’s own classification. The vertique-input-processing reference documents the owner set and its bounds in full.

Two startup failures follow (see Startup failures), both of which previously surfaced per message: a type in that set whose names cannot be projected, and a reachable type whose policy annotations conflict — preparing the owner set resolves that type’s policy metadata.

Three shapes a declared policy still does not reach. A Map-typed field, an Object-typed field, and a concrete @JsonTypeInfo subtype’s own fields all leave the field with its inherited method- and type-level chains and nothing else. Nothing fails and nothing is logged, so a stranded policy on one of these is invisible until the message that mattered gets through. A renamed key, a key a codec promoted out of an @JsonUnwrapped member, and a key matched case-insensitively are all covered now, and a governed field the mapper binds under a different property name — @Sanitize on streetName behind setStreet — fails registration rather than passing silently. The vertique-input-processing reference documents each shape, what still applies, and how to stay inside the covered set.


When AuthModule is installed, each connection is registered as a channel with dev.vertique.security.channel.ChannelIdentityManager (from dev.vertique:vertique-security-core). Use it to swap a live connection’s identity after, for example, a token rotation:

@Singleton
public class SessionRefresher {
private final ChannelIdentityManager channels;
@Inject
SessionRefresher(ChannelIdentityManager channels) {
this.channels = channels;
}
public Future<Void> refresh(WebSocketSession session, SecurityContext newContext) {
return channels.refreshIdentity(session.id(), newContext);
}
}

Compose on the returned future — it completes only once the new identity is in effect. The refresh replaces the SecurityContext only; correlation and MDC bound at upgrade survive it.

The Vert.x authorization import (opt-in via VertxAuthorizationImportModule from dev.vertique:vertique-rest-security) runs during identity resolution at upgrade time only. A refresh rebinds the supplied SecurityContext as-is — it does not re-invoke identity resolvers or contributed AuthorizationProviders. Resolve any refreshed authorities into the new context before calling refreshIdentity.

@OnClose runs before the channel is deregistered, so your close handler still observes the authenticated SecurityContext rather than an anonymous one. Cleanup — the channel-closed event, expiry-timer cancellation, and scope release — happens after your close logic has settled.

Without AuthModule there is no ChannelIdentityManager binding and no channel registration; the connection’s context scope is released directly on close.


WebSocketModule (dev.vertique.rest.websocket.dagger) includes RestCoreModule and SecurityEventsModule. It declares exactly two @Multibinds and six @BindsOptionalOf declarations; identity resolution, authorization, claim mapping, and Vert.x authorization import are no longer separately declared here — they live entirely behind the single IdentityPipelineFactory optional binding, owned by dev.vertique:vertique-rest-security’s AuthModule (issue #256; a second module declaring those collaborators would re-create the duplicate assembly that change closed).

Binding Purpose
@Multibinds @WebSocketEndpoints Set<Object> Empty default; applications contribute endpoints
@Multibinds Set<RouteAuthHandler> Empty default so the graph resolves with no auth module present; AuthModule (or dev.vertique:vertique-rest-auth-jwt) contributes into the same set
WebSocketConfig (@Singleton) Parsed from the websocket config section
@ElementsIntoSet Set<RouterMount> Contributes one WebSocketMount at priority -100, or nothing when no endpoint is contributed

Optional bindings (@BindsOptionalOf), each absent unless the named module is in the component. All coalesce with the same declaration in AuthModule when both are present.

Optional binding Supplied by Absent means
IdentityPipelineFactory (dev.vertique.rest.security.IdentityPipelineFactory) AuthModule No authentication, identity resolution, or authorization on any endpoint; a restrictive/@RequiresAction endpoint fails registration closed instead of registering unauthenticated (see The upgrade is the security boundary)
ChannelIdentityManager AuthModule No channel registration; no identity refresh
Authorizer SecurityAuthzModule Any endpoint declaring @RequiresAction fails startup
ActionRegistry SecurityAuthzModule Same
BeanValidator ValidationModule Messages are not validated
InputObjectProcessor (dev.vertique.input.processing.InputObjectProcessor) SanitizationModule Messages and path parameters are not sanitized — and any endpoint that declares a policy fails startup rather than accepting messages unprocessed

Failures, Constraints, and Common Mistakes

Section titled “Failures, Constraints, and Common Mistakes”

All of these are raised while the router is built, so a misconfigured endpoint never serves traffic.

Condition Thrown
Endpoint instance not annotated @WebSocketEndpoint IllegalArgumentException
Two methods carry the same lifecycle annotation IllegalArgumentException
A lifecycle method returns something other than void or Future IllegalArgumentException
@PathParam("x") names no placeholder in the path template IllegalArgumentException
Conflicting security annotations on the class IllegalArgumentException
@RolesAllowed with an empty value list IllegalArgumentException
@DenyAll, @RolesAllowed, @PermitAll, or @Authorized on a lifecycle method IllegalArgumentException
@RequiresPolicy on a lifecycle method IllegalArgumentException
@RequiresPolicy naming an invalid policy, distinct policies across the endpoint’s type hierarchy, or a policy mixed with an inline security annotation IllegalArgumentException
@RequiresAction on a lifecycle method IllegalArgumentException
@RequiresAction that is not a canonical action reference IllegalArgumentException
@RequiresAction combined with @PermitAll or @DenyAll IllegalArgumentException
@RequiresAction with no ActionRegistry installed IllegalArgumentException
@RequiresAction naming an action absent from the ActionRegistry IllegalArgumentException
@RequiresAction with no authorization enforcement pipeline installed IllegalStateException
@RequiresPolicy resolving to a restrictive policy or an action, with no authorization enforcement pipeline installed IllegalStateException
@RequiresAction with an ActionRegistry but no Authorizer IllegalStateException
One or more endpoints declare a restrictive policy (@DenyAll, @RolesAllowed, @Authorized) or class-level @RequiresAction, but no IdentityPipelineFactory is bound (no AuthModule) — checked for every contributed endpoint before any authentication handler is installed; the one exception aggregates every violating endpoint class and its policy IllegalStateException
Endpoint needs authentication but no RouteAuthHandler is registered IllegalStateException
authScheme names no registered RouteAuthHandler IllegalStateException
Several RouteAuthHandlers registered and no authScheme given IllegalStateException
A wire-name projection in a message type’s owner set cannot be composed — two properties claiming one wire name, or two claiming one @JsonAlias (checked only when an InputObjectProcessor is bound) ConfigurationException
A type in a message type’s owner set declares conflicting policy annotations (checked only when an InputObjectProcessor is bound) IllegalStateException
An endpoint class, lifecycle method, or lifecycle-method parameter declares both a policy annotation and the matching skip annotation anywhere in its type hierarchy InvocationPolicyConflictException (an IllegalStateException)
An endpoint declares a canonicalizer or sanitizer chain — on the class, a lifecycle method, or a lifecycle-method parameter, directly or through a composed annotation — or the message type declares field-level policies, while no InputObjectProcessor is bound ConfigurationException
@OnMessage declares more than one payload-eligible parameter ConfigurationException

Every @RequiresAction failure mode above, and the same failures for an action carried by a @RequiresPolicy, is deliberately fail-closed: an action gate that cannot be enforced refuses to boot rather than serving traffic with the gate silently missing. The restrictive-policy-without-pipeline row is the same discipline applied to role/scope policies: a @DenyAll endpoint with no AuthModule installed would otherwise register open, and a graph that binds a RouteAuthHandler without the pipeline would authenticate but never authorize.

Outcome What the client sees
Authentication or authorization denied The security layer’s HTTP status (401 or 403) on the handshake; no 101
toWebSocket() failed HTTP 400, or Vert.x’s own answer when it already ended the response (400, 405 or 426); either way the request is unclaimed and yields one HttpRequestCompletedEvent
Session bootstrap threw, channel registration failed, or @OnOpen failed Handshake succeeds, then an immediate close with code 1011 and no frames
Peer closed while channel registration was still in flight @OnOpen is skipped entirely and the connection is torn down
Normal session.close() Close code 1000

No request completion event for an upgraded connection. This module claims each successfully upgraded request for its own lifecycle, so rest-core’s completion emitter publishes no completion event for it — neither a RestRequestCompletedEvent nor an HttpRequestCompletedEvent — not at the 101 and not when the connection later closes. Observe the connection through @OnOpen and @OnClose instead and, when AuthModule registers it as a channel (see Identity refresh), through the channel events ChannelOpenedEvent and ChannelClosedEvent (from dev.vertique:vertique-security-core) delivered to every SecurityEventObserver. A rejected or failed upgrade is not claimed: a handshake the security layer rejects with 401 or 403, or one whose toWebSocket() failed and was answered with 400 by this module or with 400, 405 or 426 by Vert.x itself, completes as an ordinary HTTP request and produces exactly one HttpRequestCompletedEvent.

  • Expecting per-message authorization. There is none. A @DenyAll, @RolesAllowed, @PermitAll, @Authorized, @RequiresAction, or @RequiresPolicy on a lifecycle method fails startup for exactly this reason — it would suggest a guarantee the transport cannot make. Put the annotation on the endpoint class instead; WebSocket authorizes once at upgrade.
  • Treating an admitted upgrade as permission to run protected work. @RequiresPolicy on the endpoint only admits the connection. Put the policy on the service the message calls; code that bypasses the service client is not checked.
  • Mixing @RequiresPolicy with an inline @RolesAllowed, @Authorized, @PermitAll, @DenyAll or @RequiresAction. Startup fails; declare the requirements in the policy or inline, not both.
  • Storing the SecurityContext in session.attributes() at @OnOpen. It goes stale across an identity refresh. Read it per invocation instead.
  • Assuming a class-level @Canonicalize/@Sanitize is inert. It governs every lifecycle method of the endpoint, including the @PathParam values they bind. Declare the chain on the one method — or the one parameter — whose input it should govern when endpoint-wide reach is not what you want. Only the bound argument is processed: session.pathParams() still returns the raw client values.
  • Using a skip annotation to remove an inherited chain from the same element. A method (or parameter) carrying both @Sanitize and @SkipSanitization — one of them inherited from an interface or superclass — fails startup instead of picking one. Replace the chain rather than cancelling it.
  • Expecting binary frames to be sanitized. A Buffer payload reaches @OnMessage unprocessed; validate it yourself.
  • Assuming a failed @OnMessage closes the connection. It does not; the failure is routed to @OnError and the socket stays open. Close it yourself if that is the intent.
  • Sending from @OnOpen and expecting inbound frames first. Outbound writes work immediately; inbound delivery starts only after @OnOpen’s future completes.
  • Using a @PathParam type other than String, int/Integer, or long/Long. This passes startup validation and fails per-invocation.
  • Adding a Vert.x AuthorizationProvider without installing VertxAuthorizationImportModule. A contributed provider is inert on its own — role and scope decisions are evaluated from the framework’s SecurityContext claims. When the application includes the opt-in VertxAuthorizationImportModule (from dev.vertique:vertique-rest-security), the security-owned IdentityPipelineFactory threads the importer into the handler this module installs, so contributed providers change authorization outcomes at upgrade time. WebSocketModule itself no longer declares the provider/importer bindings — they live entirely behind AuthModule’s IdentityPipelineFactory.

Dependency Why
dev.vertique:vertique-rest-core RouterMount, request-lifecycle handle, SecurityRuntime, RouteAuthHandler, the request-completion claim for a successful upgrade
dev.vertique:vertique-input-processing the neutral InputObjectProcessor / EffectiveInputPolicies contracts message and path-parameter processing are typed against
dev.vertique:vertique-json JacksonFieldNameResolver — the wire-name projection that lets a declared policy reach a renamed message field
dev.vertique:vertique-rest-security Policy enforcement, identity resolution, claim mapping
dev.vertique:vertique-core Context holder, config parsing, Bean Validation and sanitization contracts, including the InputFieldNameResolver projection contract message processing is typed against
dev.vertique:vertique-context ContextSnapshot/ContextValues used to carry request context across the handshake
dev.vertique:vertique-security-core SecurityContext, ChannelIdentityManager, action-gate types
dev.vertique:vertique-security-runtime Security event emission for the connection lifecycle
dev.vertique:vertique-logging Logging conventions
io.vertx:vertx-web Router, routing context, ServerWebSocket
io.vertx:vertx-auth-common Vert.x authorization provider types on the security seam
jakarta.ws.rs:jakarta.ws.rs-api @PathParam and the JAX-RS security annotations
com.fasterxml.jackson.core:jackson-databind Typed message serialization
com.google.dagger:dagger Module and multibinding declarations
org.projectlombok:lombok Compile-time only