Skip to content
JavaAgentic

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

Zero-Trust Architecture

Building zero-trust in practice: the NIST model, workload identity with SPIFFE, mTLS everywhere, per-request authorization, micro-segmentation, and just-in-time access.

Expert6 min readUpdated
On this page

Zero-trust replaces the assumption that the internal network is safe with a rule that every request is verified regardless of where it comes from. For a Java estate that means workload identity, mTLS everywhere, and authorization on every call rather than at the perimeter.

Key Takeaways

  • Never trust network location. A request from inside the cluster is verified like any other.
  • NIST SP 800-207 defines the roles: policy engine, administrator, enforcement point.
  • Workload identity (SPIFFE) replaces "this IP is trusted" with "this workload is who it claims".
  • Continuous authorization re-evaluates every request, so access can be withdrawn mid-session.
  • Adopt incrementally — the highest-value controls first.

The principle

The perimeter model trusts everything inside. Zero-trust verifies every request, so a foothold does not become the whole estate.

The motivation is the failure mode of the perimeter model: once an attacker is inside — through a compromised dependency, a phished credential, a misconfigured pod — they can reach everything, because internal traffic is trusted. Zero-trust removes that assumption, so a foothold yields only what the compromised identity was permitted.

The NIST model

SP 800-207 names three logical components:

  • Policy Engine (PE) — decides whether to grant access, given identity and context.
  • Policy Administrator (PA) — establishes or tears down the connection the PE authorised.
  • Policy Enforcement Point (PEP) — sits in the request path and enforces the decision.

In a Java-on-Kubernetes estate these map onto familiar tools: OPA as the policy engine, a service mesh or a filter as the enforcement point, and an identity system feeding both.

Workload identity

The foundation is that each workload has a cryptographic identity, not an IP address:

spire-registration.yaml
# SPIRE issues each workload an SVID (SPIFFE Verifiable Identity Document)
# based on attestation — what node it runs on and what workload it is —
# rather than trusting whatever a network packet claims.
spiffeID: "spiffe://acme.com/ns/production/sa/order-service"
parentID: "spiffe://acme.com/spire/agent/k8s/node-1"
selectors:
  - "k8s:ns:production"
  - "k8s:sa:order-service"
  - "k8s:container-image:ghcr.io/acme/order-service@sha256:..."
SvidAuthentication.java
// The identity travels in the mTLS certificate. A service reads the caller's
// SPIFFE ID from the verified client certificate, not from a header.
public String callerIdentity(X509Certificate clientCert) {
    return clientCert.getSubjectAlternativeNames().stream()
            .filter(san -> san.get(0).equals(6))       // URI type
            .map(san -> (String) san.get(1))
            .filter(uri -> uri.startsWith("spiffe://"))
            .findFirst()
            .orElseThrow(() -> new UnauthenticatedException("no workload identity"));
}

The attestation is what makes this strong. An identity is issued based on verifiable facts about the workload — its namespace, service account, and image digest — so a compromised pod cannot claim to be a different service.

mTLS everywhere

istio-strict-mtls.yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: { name: default, namespace: production }
spec:
  mtls: { mode: STRICT }     # plaintext between workloads is rejected

Every service-to-service call is now mutually authenticated and encrypted, with certificates rotated automatically. This is the single highest-value zero-trust control and, with a mesh, close to free — which is why it is the place to start.

Continuous authorization

Perimeter authentication decides once, at login. Zero-trust decides on every request, against current context:

ContinuousAuthorization.java
@Component
public class ContextualAuthorizationFilter implements GlobalFilter {
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return buildContext(exchange)
                .flatMap(policyEngine::evaluate)      // OPA decision per request
                .flatMap(decision -> decision.allowed()
                        ? chain.filter(exchange)
                        : deny(exchange, decision.reason()));
    }
 
    private Mono<AuthorizationContext> buildContext(ServerWebExchange exchange) {
        // The decision considers far more than identity: where the request
        // came from, what device, at what time, and how anomalous it looks
        // against the user's normal behaviour.
        return Mono.zip(
                identity(exchange),
                devicePosture(exchange),
                Mono.just(exchange.getRequest().getRemoteAddress()),
                anomalyScorer.score(exchange))
            .map(t -> new AuthorizationContext(t.getT1(), t.getT2(), t.getT3(), t.getT4()));
    }
}
policy.rego
package acme.access
 
default allow := false
 
allow if {
    input.identity.authenticated
    input.device.compliant                    # patched, encrypted, managed
    input.anomaly_score < 0.7
    valid_for_resource
}
 
# Step-up: a sensitive action needs recent strong authentication regardless
# of an existing session.
allow if {
    input.action == "transfer_funds"
    input.identity.mfa_age_seconds < 300
    input.device.compliant
}

The consequence that distinguishes this from ordinary authorization: because every request is evaluated, access can be withdrawn mid-session. A device that falls out of compliance, an anomaly score that spikes, a credential flagged as compromised — any of these blocks the next request rather than waiting for a token to expire.

Micro-segmentation

network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: order-service, namespace: production }
spec:
  podSelector:
    matchLabels: { app: order-service }
  policyTypes: [Ingress, Egress]
  ingress:
    # Only the gateway and the checkout service may call orders.
    - from:
        - podSelector: { matchLabels: { app: api-gateway } }
        - podSelector: { matchLabels: { app: checkout-service } }
  egress:
    # Orders may reach only its database and the payment service.
    - to:
        - podSelector: { matchLabels: { app: postgres } }
        - podSelector: { matchLabels: { app: payment-service } }

Deny-by-default network policy is the segmentation layer. Combined with mTLS identity, a compromised service can reach only the specific peers its policy permits — so lateral movement, the technique that turns one foothold into a full compromise, is blocked at the network as well as at the application.

Just-in-time access

Standing administrative access is a large attack surface. Grant it on request, scoped and expiring:

JitAccess.java
@Service
public class JustInTimeAccessService {
 
    public AccessGrant request(String userId, String resource, String justification) {
        // Elevated access is never permanent. It is requested, approved,
        // granted for a window, and revoked automatically.
        var approval = approvalWorkflow.require(userId, resource, justification);
 
        return approval.granted()
                ? vault.issueTemporaryCredential(resource, Duration.ofHours(2))
                : AccessGrant.denied(approval.reason());
    }
}

An engineer needing production database access requests it with a reason, a peer or manager approves, and the credential is valid for two hours. There is no standing credential to steal, and every access is attributable and time-boxed.

Adopting incrementally

Zero-trust is a direction, not a switch. Each step delivers value on its own, which is what keeps the migration alive.

The failure mode of zero-trust programmes is attempting everything at once, stalling under the complexity, and delivering nothing. Adopt in order of value. mTLS and per-request authorization at your most sensitive service give most of the benefit; the rest extends the model as capacity allows.

Maturity models describe the journey as traditional, advanced and optimal. The useful framing is simpler: each control you add shrinks what a single compromise can reach, and you can stop at whatever level matches your threat model.

What to take away

Stop trusting network location. Give workloads cryptographic identities and require mTLS between them. Authorize every request against current context, not once at login, so access can be withdrawn when conditions change. Segment the network deny-by-default, remove standing privilege with just-in-time access, and adopt all of it incrementally, highest-value first.

Frequently Asked Questions

Is zero-trust a product I can buy?
No, it is an architecture and a set of principles. Vendors sell components that help — identity providers, service meshes, policy engines — but zero-trust is the design decision to never trust based on network location and to verify every request. You assemble it; you do not install it.
Do I have to adopt it all at once?
No, and you should not. Start with the highest-value moves — mTLS between services and per-request authorization at the most sensitive service — and expand. A big-bang migration to zero-trust is how the project stalls; incremental adoption delivers value at each step.
What is the difference between authentication and continuous authorization here?
Authentication happens once and establishes identity. Continuous authorization evaluates every request against current context — the identity, the device posture, the resource, the time, an anomaly score — so access can be withdrawn mid-session when conditions change, rather than lasting until a token expires.

Related tutorials