Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

SecureX — Zero-Trust Platform Architecture

The capstone architecture: an auth server, security gateway, OPA policy engine, secrets service, audit service and self-service portal, and how they fit together.

Expert5 min readUpdated
On this page

This is the architecture the Spring Security track builds toward: a platform where every request is authenticated, authorised against policy, and recorded — assembled from the components covered individually across the preceding topics.

Key Takeaways

  • Six components: auth server, gateway, policy engine, secrets, audit, portal.
  • The policy engine is on every request path — it must be fast and fail closed.
  • The audit service must not be on the critical path; buffer instead of blocking.
  • Workload identity plus mTLS underpins everything else.
  • Build the pieces specific to your domain; buy identity.

The architecture

Six components. The gateway and policy engine sit on every request; the audit service observes without blocking.

Auth server

Spring Authorization Server issuing OIDC tokens, federating to enterprise identity providers, and enforcing MFA:

AuthServerConfig.java
@Bean
AuthorizationServerSettings settings() {
    return AuthorizationServerSettings.builder()
            .issuer("https://auth.acme.com")
            .build();
}
 
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(UserService users) {
    return context -> {
        if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) return;
        var user = users.findByUsername(context.getPrincipal().getName());
        context.getClaims().claims(claims -> {
            claims.put("tenant_id", user.tenantId());
            claims.put("roles", user.roleNames());
            // The policy engine uses this: an action requiring recent strong
            // authentication checks how long ago MFA happened.
            claims.put("mfa_at", user.lastMfaAt().getEpochSecond());
        });
    };
}

The mfa_at claim is what enables step-up authorization downstream — a policy can require MFA within the last five minutes for a sensitive action without the resource server needing to know how MFA works.

Security gateway

GatewayFilters.java
@Component
public class PolicyEnforcementFilter implements GlobalFilter, Ordered {
 
    private final PolicyClient opa;   // localhost sidecar
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return opa.evaluate(buildInput(exchange))
                .flatMap(decision -> decision.allow()
                        ? chain.filter(exchange)
                        : forbidden(exchange, decision.reason()))
                // Fail CLOSED. An authorisation layer that cannot decide must
                // deny — unlike a rate limiter, where failing open is correct.
                .onErrorResume(ex -> {
                    log.error("policy evaluation failed", ex);
                    return forbidden(exchange, "policy unavailable");
                });
    }
 
    @Override public int getOrder() { return -50; }   // after auth, before routing
}

The gateway rejects bad traffic early, but every service still validates independently — the gateway is an optimisation, not a trust boundary.

Policy engine

policies/access.rego
package acme.access
 
import rego.v1
 
default allow := false
 
allow if {
    input.subject.authenticated
    input.subject.tenant_id == input.resource.tenant_id      # tenant isolation
    permitted_action
    not high_risk
}
 
# Step-up for sensitive operations regardless of an existing session.
permitted_action if {
    input.action in {"read", "list"}
    some role in input.subject.roles
    role in {"USER", "MANAGER", "ADMIN"}
}
 
permitted_action if {
    input.action in {"refund", "delete"}
    "ADMIN" in input.subject.roles
    time.now_ns() / 1000000000 - input.subject.mfa_at < 300
}
 
high_risk if input.context.anomaly_score > 0.8
high_risk if input.context.device_compliant == false

Policies live in Git, are tested with opa test in CI, and are distributed as bundles to every sidecar. That gives reviewable, versioned authorisation with an audit trail of who changed what.

Secrets service

Vault with Kubernetes authentication and dynamic database credentials, so no service holds a long-lived password:

application.yml
spring:
  cloud:
    vault:
      authentication: KUBERNETES
      kubernetes: { role: order-service }
      database:
        enabled: true
        role: order-service-db     # credentials created per workload, 1h lease

Audit service

The component that must not block:

AuditIngest.java
@Component
public class AuditIngest {
 
    // Events arrive asynchronously over Kafka. A slow SIEM or a full index
    // creates a backlog, not a request failure.
    @KafkaListener(topics = "audit-events", groupId = "audit-service")
    public void ingest(AuditEvent event) {
        var chained = event.withPreviousHash(repository.latestHash());
        repository.append(chained);       // append-only, hash-chained
        siemForwarder.forward(chained);
    }
}

The design decision here is the split. Events that must never be lost — a permission change, a financial transaction — are written in the same transaction as the action by the originating service. Everything else flows through Kafka to the audit service, where a backlog degrades latency of the audit trail rather than availability of the application.

Self-service portal

The component teams underestimate. Users need to manage their own security without a support ticket: enrol MFA, view and revoke active sessions, see login history, manage API keys, review connected applications, and download their data.

Each of those is a support ticket avoided and, more importantly, a way for users to notice something wrong. A session list showing a login from an unexpected country is how account compromise gets detected.

What runs where

ComponentCritical pathFailure mode
Auth serverLogin onlyNo new logins; existing tokens work
GatewayEvery requestTotal outage — needs replicas
Policy engineEvery requestFail closed — sidecar, not central
SecretsStartup and lease renewalServices fail to start or renew
AuditNoBacklog, buffered
PortalNoSelf-service unavailable

The sidecar decision for the policy engine follows from that table: something on every request path must not be a network hop to a shared service, because that adds latency and a shared failure domain to everything.

Building versus buying

Be clear-eyed about scope. This architecture describes perhaps eighteen months of work for a small team, and most of it duplicates products that exist.

Buy or adopt: the auth server (Keycloak, Auth0, Okta), the secrets service (Vault, cloud KMS), the SIEM. These are commodity, and building them diverts your best engineers from your domain.

Build: the policy content specific to your business rules, the audit event taxonomy for your domain, and the self-service portal, which is product surface only you can design.

The value of understanding the whole architecture is knowing what you are assembling and where the seams are — not building all of it.

What to take away

Six components, of which only the gateway and policy engine are on every request path. Workload identity and mTLS underneath, policy in Git and tested in CI, audit off the critical path and hash-chained. Buy identity and secrets management; build the policy content, audit taxonomy and portal that are specific to your domain.

Frequently Asked Questions

Should a team actually build this?
Rarely all of it. The value of the exercise is understanding how the pieces fit, so you can assemble them from existing products with informed judgement. Most organisations should buy identity and build only the policy and audit layers specific to their domain.
What is the hardest component to get right?
The policy engine integration, because it sits on every request path. It must be fast, fail closed, and have policies that are testable and reviewable. A policy layer that adds 50ms per request or fails open under load undermines the whole architecture.
How do the components fail independently?
They should not all be on the critical path. The gateway and policy engine are; the audit service and portal are not. Design so an audit backlog degrades to buffered writes rather than blocking requests, and so a portal outage does not affect authentication.

Related tutorials