---
title: Authenticate and authorize Arc endpoints
description: Register Kotlin and Java authentication handlers, declare endpoint authorization, and expose application identity details safely.
---


## 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

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:

```java
@Bean
SecurityFilterChain 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

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

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:

```kotlin
@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

Java implements `AsyncAuthenticationHandler` with `CompletionStage`, without coroutine types:

```java
@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

Arc generates authorization metadata from `@Authorize`, `@Roles`, and `@AllowAnonymous` on a command or read-model class and its operation (`handle` or the query function).

```kotlin
@Authorize(policy = "activeSubscription")
@Roles("member")
@Command
class UpdateProfile {
    fun handle(): Unit = Unit
}
```

An authorization decision with several dimensions requires all declared dimensions:

- The caller must be authenticated unless `@AllowAnonymous` applies.
- 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`:

```kotlin
@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

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:

```kotlin
@ReadModel
@Authorize
public 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.

:::caution[One declaration cannot both open and restrict]
`@AllowAnonymous` together with `@Authorize` or `@Roles` **on the same class or the same operation** is a contradiction with no defensible reading. KSP reports `ARCKSP0108` and stops generation rather than guessing. Splitting them across a class and its operation is a different thing entirely, and is exactly the override above.
:::

## 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

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:

```kotlin
data class ApplicationIdentity(val displayName: String)

@Bean
fun 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

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/commands`
- `GET /.cratis/queries`
- `GET /.cratis/users`
- `GET /.cratis/tenants`
- `GET /.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

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.
