Security Baked Into the JVM: why fork Apache River and OpenJDK?
The more distributed a system, the harder it is to secure. Code crosses JVM boundaries. Objects are serialized across trust boundaries. Third-party proxies run inside your process. The usual answer is a network firewall. It helps, but it operates at the wrong level.
Java 17 deprecated the `SecurityManager`, Java 24 put the final nail in its coffin. Most developers didn’t notice. Applications that load and execute remote code did: the JVM no longer has a built-in mechanism to restrict what that code can do.
The `SecurityManager` removal forced a rethink of the whole security architecture from scratch. That turned out to be an opportunity. The authorization problem and the service-discovery problem are separate. Each has a dedicated project. They are designed to be used together.
- DirtyChai: a community fork of OpenJDK that restores Java’s authorization infrastructure; required to run JGDMS.
- JGDMS: a security-hardened fork of Apache River, providing dynamically-discoverable microservices over IPv6.
DirtyChai scales vertically. JGDMS scales horizontally.
DirtyChai adds virtual thread support and a lock-free policy engine to a single JVM. JGDMS makes a fleet of those JVMs discoverable, self-registering, and self-healing.
In this series, I want to explore how the two projects push security into every layer of the platform. Each post covers one layer: from codebase auditing to identity to authorization to performance.
This post focuses on the two projects: what they are, what they are not, and a first taste of deployment.
JGDMS
JGDMS is described in its own project descriptor as:
Infrastructure for providing secured micro services, that are dynamically discoverable and searchable over IPv6 networks.
Most RPC frameworks stop at calling a remote method. JGDMS goes further: services announce themselves on IPv6 networks, clients find them by capability rather than hard-wired address, and trust is established cryptographically before any code runs. Three concepts drive the design:
Jini-model service discovery
lease-based registrations, multicast and unicast lookup, event-driven service notifications.
JERI
a pluggable, constraint-based RPC layer that supersedes standard Java RMI with pluggable transport, per-method security requirements, and authenticated dispatch.
Defence-in-depth security
hardened deserialization, TLSv1.3 transport, Java authorization, proxy trust verification, and a codebase safety pipeline that analyzes third-party bytecode before it is ever loaded.
What JGDMS Is Not
JGDMS is not a sandbox for running untrusted code. It will not safely isolate malicious bytecode. The goal is the opposite: prevent untrusted code from ever being loaded. `LoadClassPermission` is the gate; SCAP (covered in Part 2) pre-analyzes JARs before any client deserializes an object from them. If you need to run code you don’t trust, you need a different tool.
Running on bare OpenJDK is not supported. On standard OpenJDK ≤ 23, virtual threads get an `AccessControlContext` with no permissions when `SecurityManager` is enabled: they simply don’t work in a security context. On OpenJDK 24+, `SecurityManager` was removed entirely. DirtyChai is the only supported runtime JDK.
Building is a different story. JGDMS is compile-time compatible with standard OpenJDK: you can build with any standard OpenJDK toolchain. DirtyChai is binary compatible with software compiled on OpenJDK, so no recompilation is needed when switching runtimes.
DirtyChai
DirtyChai is a community fork of OpenJDK that restores, improves, and extends Java’s authorization infrastructure: the `SecurityManager`, `AccessController`, and `ProtectionDomain` APIs that OpenJDK deprecated in Java 17 and removed entirely in Java 24.
Without these APIs you cannot restrict what code from a particular source can do once it is loaded. DirtyChai’s goal is not a sandbox. Its goal isn’t to run untrusted code safely, but to ensure trusted but independent parties operate only within their declared and granted privileges.
| Feature | Source | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Prevent loading of untrusted code | LoadClassPermission | | Break deserialization gadget attack chains | SerialObjectPermission | | Block native code injection | NativeInvocationPermissionNativeMemoryPermission | | Maintain and extend permission guard hooks | guards | | SPIFFE/SPIRE zero-touch certificate management | SpiffeX509TrustManagerSpiffeX509KeyManager |
A minimal JGDMS deployment
In three steps, we define a remote interface, configure the launcher, and start the service. Pay attention to Step 2 below: authentication, encryption, and hardened deserialization are declared in the deployment configuration and bound to every call by the exporter — never written into the service API. The interface stays a plain `Remote` interface, so the same service redeploys under different security postures (relaxed in dev, mutual TLS in production) without touching a line of code. A call that can’t satisfy those constraints fails before a single byte leaves the client JVM. Standard Java RMI has nothing like this; security there is opt-in, scattered through application code.
The quickest path is a `ServiceStarter` configuration.
Step 1: Define the service interface
public interface HelloService extends Remote {
String greet(String name) throws RemoteException;
}That is the entire API: a contract, nothing else. No security code, no constraints — those are a deployment concern, declared next.
Step 2: Write the ServiceStarter configuration
hello-service.config
import net.jini.jeri.*;
import net.jini.jeri.ssl.*;
import net.jini.constraint.*;
import net.jini.core.constraint.*;com.sun.jini.start {
serviceDescriptors = new ServiceDescriptor[] {
new NonActivatableServiceDescriptor(
"file:hello-service-impl.jar", (1)
"file:hello-service-dl.jar", (2)
"net.example.HelloServiceImpl", (3)
new String[]{ "hello-service.config" } (4)
)
};
}
net.example.HelloServiceImpl {
private methodConstraints = new BasicMethodConstraints( (5)
new InvocationConstraints(
new InvocationConstraint[] {
ServerAuthentication.YES, // server presents a valid certificate
ClientAuthentication.YES, // client must authenticate
Confidentiality.YES, // TLSv1.3 encryption
Integrity.YES, // MAC-covered
AtomicInputValidation.YES // hardened deserialization of every argument
},
null));
serverExporter = new BasicJeriExporter(
SslServerEndpoint.getInstance(0), (6)
new BasicILFactory(methodConstraints, null) (7)
);
}
| 1 | Implementation JAR | | ----- | ----------------------------------------------------------------------------- | | 2 | Downloadable proxy JAR | | 3 | Implementation class | | 4 | Configuration passed to the service | | 5 | Security requirements — declared in deployment config, not in the service API | | 6 | TLS on a random port | | 7 | Bind the constraints to every exported call |
Step 3: Launch
java -Djava.security.policy=start-service.policy \
-jar lib/start.jar hello-service.configThe service self-registers with the Jini Lookup Service at `lookup.example.org:4160`. Any client that finds the lookup service can discover `HelloService` by interface type, without hard-wired addresses or service registry configuration.
The constraints declared in the configuration travel with the proxy and are enforced at the client, before any I/O:
Conclusion
The quick start shows security enforced at the call level: authentication, encryption, and hardened deserialization, all declared in configuration and enforced at the call boundary. That is one layer. There is another: controlling what code gets loaded in the first place. In the next post, I’ll describe SCAP, the pipeline that audits third-party JARs before any client ever deserializes an object from them.


