Authenticate and authorize Arc endpoints
Establish trust before accepting platform headers
Section titled “Establish trust before accepting platform headers”The optional platform identity bridge is off by default. Base64 is unsigned data, never proof of
sender identity. Enable cratis.arc.platform-identity.enabled=true only behind authenticated ingress
that strips caller-supplied x-ms-client-principal, x-ms-client-principal-id, and
x-ms-client-principal-name headers and rewrites them from verified identity. Block direct backend
access. Header parsing alone cannot establish either of these deployment guarantees.
Supply an ArcPlatformIdentityTrust bean that verifies your deployment’s ingress boundary on each
submission. Without it, the default policy rejects every platform submission. Do not return true
because X-Forwarded-For, Forwarded, or another caller-authored header claims a trusted address.
An exact request.remoteAddr comparison can be part of an isolated deployment’s policy, but only
when it represents a verified transport peer. Forwarded-request wrappers and container valves can
rewrite it: disable address rewriting or configure and verify trusted proxy handling before using
this value. Do not resolve caller-provided hostnames or grant trust to the public client address.
Arc does not install forwarding configuration, TLS credentials, firewall rules, or proxy allowlists.
Spring Security remains optional. With security absent or the property disabled, Arc registers no platform converter, filter, or chain, and platform headers do not authenticate requests.
Choose who owns the security chain
Section titled “Choose who owns the security chain”When enabled and no application SecurityFilterChain exists, Arc supplies a conservative chain:
every route requires authentication, including otherwise anonymous Arc metadata routes. CSRF,
security headers, and Spring’s session defaults remain enabled; POST and other unsafe requests need
a valid CSRF token. Missing authentication returns 401, access denial (including CSRF) returns 403,
without login redirects or error redispatch. Form login and HTTP Basic are not added by this chain.
This policy deliberately favors denial over accidentally exposing application routes; configure an
application chain for any anonymous routes or alternative authentication mechanisms.
An application chain is never rewritten. Explicitly add the provided filter after your existing
authentication mechanisms and before AnonymousAuthenticationFilter, as in this Java configuration:
@BeanSecurityFilterChain applicationChain(HttpSecurity http, ArcPlatformAuthenticationFilter filter) throws Exception { http.addFilterBefore(filter, AnonymousAuthenticationFilter.class); http.authorizeHttpRequests(rules -> rules.requestMatchers("/.cratis/commands").permitAll() .anyRequest().authenticated()); http.exceptionHandling(errors -> errors.authenticationEntryPoint( (request, response, failure) -> response.setStatus(401))); return http.build();}Keep the disabled arcPlatformAuthenticationFilterRegistration bean: it prevents Boot servlet
registration and double execution outside Spring Security. With multiple application chains, install
the filter only in chains intended to accept this identity. A chain without it ignores the headers.
Do not register it independently as a servlet filter. Applications may replace the trust bean, the
named arcPlatformAuthenticationConverter (AuthenticationConverter), or the filter bean; custom
converters own equivalent validation and must report rejected credentials as AuthenticationException.
Unrelated converters do not disable the named default.
Valid platform submissions never replace an already authenticated non-anonymous Spring identity. Invalid submissions fail with a generic 401 even when an existing identity is present (CSRF or an earlier security filter may reject first). The bridge saves its context only in request attributes for redispatch, never in the HTTP session; keep Spring’s request-attribute security-context repository support when customizing a chain. The servlet thread context is restored on exit. Arc captures the resulting principal before coroutine work, without later servlet or security thread-local reads. The client-readable identity cache cookie cannot authenticate a later request.
Understand the bounded header contract
Section titled “Understand the bounded header contract”A submission requires exactly one of each of the three headers; no headers means no attempt. Partial or repeated headers, invalid base64, malformed UTF-8/JSON, duplicate JSON keys, trailing values, unknown fields, incorrect types, duplicate claim pairs, duplicate roles, control characters, and exceeded limits fail with no payload or parser cause in the failure. Canonical padded standard base64 is required, not URL-safe base64 or whitespace-wrapped data. Configure your container and proxy header limits too: they may reject a request before Arc sees it.
The JSON object accepts identityProvider, userId, userDetails, userRoles, and claims only.
userDetails is a required nonblank display name. Optional userId is a nonblank string but is not
the canonical ID. Optional identityProvider is a string; missing or blank means no provider claim.
userRoles is an optional array of nonblank strings; claims is an optional array of exact
{"typ":"type","val":"value"} objects. Explicit null is not absence. Claim types are nonblank;
empty claim values are allowed. ID/name headers must be nonblank; the name header is required by the
wire contract, but the display name comes from userDetails.
Limits are fixed: 32,768 encoded characters, 24,576 decoded bytes, 2,048 characters per string/header ID/name, 128 input claims, 64 roles (including role claims), nesting depth 4, 2,048 JSON tokens, 256-character JSON property names, and 32-character numeric tokens. There is no application-mapper coercion or polymorphic type activation. Repeated claim types with distinct values remain multivalued.
The ID header replaces exact sub and the standard nameidentifier claim. Reserved
urn:cratis:arc:identity:provider collisions are removed case-insensitively, then one nonblank
identityProvider value is retained verbatim, including surrounding spaces. Other claims, including
urn:cratis:identity:provider-key, survive. Standard name and role claims are added from userDetails
and userRoles; standard role claims also become Spring ROLE_ authorities. Arc captures these as
unprefixed roles and scheme MicrosoftIdentityPlatform. This scheme describes the bridge, not an
independently verified signature or a claim that the application uses Microsoft token validation.
These JVM security boundaries are intentionally stricter than the inspected Arc .NET handler, including rejecting partial submissions rather than treating them as anonymous. Do not log the headers, claims, or identity cookie when troubleshooting ingress.
Register a Kotlin authentication handler
Section titled “Register a Kotlin authentication handler”Arc authenticates requests through ordered AuthenticationHandler beans before protected command, query, identity, and diagnostics endpoints run. A handler receives an immutable AuthenticationRequestContext containing case-insensitive headers, cookies, the principal captured by the host, and the selected tenant when one exists.
Return AuthenticationResult.ANONYMOUS when the handler does not recognize the request. Return failed when it recognizes credentials but rejects them, or succeeded with an ArcPrincipal when it accepts them:
@Bean@Order(10)fun bearerAuthentication(): AuthenticationHandler = AuthenticationHandler { context -> when (context.header("Authorization")) { null -> AuthenticationResult.ANONYMOUS "Bearer valid-token" -> AuthenticationResult.succeeded( ArcPrincipal( name = "Ada", isAuthenticated = true, roles = setOf("admin"), id = "user-42", authenticationScheme = "Bearer" ) ) else -> AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-token")) }}A success or failure is terminal: no later handler runs. A failed handler can therefore never be overridden by a later success. Anonymous is not a rejection and lets the chain continue; it becomes the final result only when no handler recognized the request. The HTTP response exposes failures only as a generic 401 and never returns the handler’s private failure reason.
Kotlin AuthenticationHandler beans execute in Spring order, followed by Java AsyncAuthenticationHandler beans in Spring order. When ordering across both implementation styles must be global, register the Java handler through AsyncAuthenticationHandlerAdapter as an AuthenticationHandler bean.
Register a Java asynchronous handler
Section titled “Register a Java asynchronous handler”Java implements AsyncAuthenticationHandler with CompletionStage, without coroutine types:
@Bean@Order(20)AsyncAuthenticationHandler apiKeyAuthentication() { return context -> { String value = context.header("X-Api-Key"); AuthenticationResult result; if (value == null) { result = AuthenticationResult.ANONYMOUS; } else if (value.equals("valid-key")) { result = AuthenticationResult.succeeded( new ArcPrincipal("Ada", true, Set.of("operator"), "user-42", List.of(), "ApiKey")); } else { result = AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-api-key")); } return CompletableFuture.completedFuture(result); };}Register the async handler itself as a bean; Arc adapts it to the coroutine-first chain and propagates cancellation. Do not block the request thread while verifying credentials.
Declare authorization on artifacts and operations
Section titled “Declare authorization on artifacts and operations”Arc generates authorization metadata from @Authorize, @Roles, and @AllowAnonymous on a command or read-model class and its operation (handle or the query function).
@Authorize(policy = "activeSubscription")@Roles("member")@Commandclass UpdateProfile { fun handle(): Unit = Unit}An authorization decision with several dimensions requires all declared dimensions:
- The caller must be authenticated unless
@AllowAnonymousapplies. - A named policy must return
AuthorizationResult.success(). - The caller must hold at least one role from the combined role list.
- The captured authentication scheme must match at least one declared scheme, ignoring case.
Register named policies as Spring beans. The bean name is the policy name used by @Authorize:
@Bean("activeSubscription")fun activeSubscription(): AuthorizationPolicy = AuthorizationPolicy { principal -> if (principal.claims.any { it.type == "subscription" && it.value == "active" }) { AuthorizationResult.success() } else { AuthorizationResult.failure("An active subscription is required.") }}Java policies use BlockingAuthorizationPolicyAdapter or AsyncAuthorizationPolicyAdapter, so Java implementations never implement a suspending method.
Understand class and operation precedence
Section titled “Understand class and operation precedence”The class states the default. An operation that declares any authorization metadata of its own — @AllowAnonymous, @Authorize, or @Roles — replaces that default entirely rather than merging with it. Only when an operation declares none does the class apply.
Replacement rather than merging is what makes narrowing trustworthy. An operation that asks for @Roles("admin") requires exactly that role, and a role the class happened to list cannot satisfy it by accident.
The same rule reads in the other direction, which is how a protected read model exposes the one query a login screen needs before anybody has signed in:
@ReadModel@Authorizepublic data class AuthenticationQueryItem(public val message: String) { public companion object { // Overrides the class: anyone may subscribe to this one. @JvmStatic @AllowAnonymous public fun anonymous(@FromServices source: AuthenticationQuerySource): Flow<AuthenticationQueryItem> = source.observeAnonymous()
// Declares nothing, so the class-level @Authorize applies. @JvmStatic public fun authenticated(@FromServices source: AuthenticationQuerySource): Flow<AuthenticationQueryItem> = source.observeAuthenticated() }}Repeated @Roles declarations on the same target combine, and holding any one listed role satisfies the role check. Policy, role, and scheme checks are still cumulative.
Use Spring Security when present
Section titled “Use Spring Security when present”Spring Security remains optional. When it is on the classpath, Arc maps the request’s Spring Authentication into an ArcPrincipal, retaining its name, authenticated state, string-valued authorities, claims, stable identity identifier, and authentication scheme. Authorities prefixed with ROLE_ become Arc role names without the prefix; authorities without a string representation are ignored.
Without Spring Security, Arc uses the servlet principal. In both cases Arc captures the principal before asynchronous work begins, so command and query pipelines never depend on thread-local security state after suspension.
An application can replace Arc’s complete Authentication service with its own bean. Supplying that override bypasses the default ordered handler aggregator and is appropriate only when the application owns the entire chain.
Expose application identity details
Section titled “Expose application identity details”Register one IdentityDetailsProvider<T> to enable GET /.cratis/me. It receives the authenticated principal and returns both the application’s authorization decision and its typed identity payload:
data class ApplicationIdentity(val displayName: String)
@Beanfun identityDetails(): IdentityDetailsProvider<ApplicationIdentity> = object : IdentityDetailsProvider<ApplicationIdentity> { override val detailsType = ApplicationIdentity::class.java
override suspend fun provide(context: IdentityProviderContext): IdentityDetails<ApplicationIdentity> = IdentityDetails( isUserAuthorized = context.claims.any { it.type == "role" && it.value == "member" }, details = ApplicationIdentity(context.name) ) }Java can register AsyncIdentityDetailsProvider<T> through AsyncIdentityDetailsProviderAdapter. Arc permits exactly one identity-details provider and fails application startup when several are present.
The identity cache cookie is client-readable by design and is excluded from authentication input, so a client can never authenticate by replaying Arc’s cached identity projection.
Know which built-in routes are anonymous
Section titled “Know which built-in routes are anonymous”The following rules describe Arc’s own authentication layer; an application or the enabled platform Spring Security chain can impose stricter rules before Arc runs. When no Arc authentication handlers are registered, Arc endpoint behavior is unchanged and the captured host principal is used. Once handlers exist, the following literal metadata routes remain anonymous:
GET /.cratis/commandsGET /.cratis/queriesGET /.cratis/usersGET /.cratis/tenantsGET /.cratis/identity-details/schema
GET /.cratis/me requires an authenticated principal because it returns caller-specific identity details.
The fixed multiplexed SSE and WebSocket routes accept an anonymous physical connection, while every subscription still runs its query’s authorization pipeline independently. One unauthorized subscription terminates without disturbing authorized subscriptions sharing the connection.
GET and QUERY /.cratis/queries/health require authentication whenever handlers are registered because the snapshot includes connection identifiers, subscription identifiers, remote addresses, user agents, and user identities. This endpoint is diagnostics, not container health; use Spring Boot Actuator health groups for liveness and readiness.
Distinguish authentication and authorization failures
Section titled “Distinguish authentication and authorization failures”Authentication establishes who the caller is and fails with HTTP 401 when credentials are rejected or a protected endpoint receives no authenticated principal. Authorization decides whether that principal may execute a specific command or query and fails with HTTP 403.
Arc returns generic transport failures and keeps private handler or policy details out of the response. Use server-side debug logs, traces, and the stable result envelope when diagnosing a rejection; never return credential or policy internals to the caller.