Alice calls the order service. The order service calls the ledger on her behalf. At the second hop, the ledger has to decide whose authority the debit is being made under.
Most stacks answer badly. Forward Alice’s bearer token verbatim, and the ledger cannot tell her from the service that relayed it. Drop the token and the ledger sees a machine, with no record that a human started the chain. Neither option lets the ledger authorize the combination.
Part 1 declared constraints on the call.
Part 2 vetted the code before a client loaded it.
Part 3 established the two kinds of Subject a call carries.
This part covers the wire: how a chain of user identities crosses a call, what the receiver refuses to believe, and how the chain survives a thread boundary and a distributed transaction.
Sixteen Subjects, Sixty-Four Principals
User Subjects travel in-band, in JERI marshalling protocol version 0x02.
That is a separate channel from the TLS handshake, and the two carry different claims.
TLS proves which machine holds the other end of the socket.
Version 0x02 carries what that machine asserts about the humans behind the call.
0x02 user-Subject block:
subjectCount : u16
per Subject:
principalCount : u16
per Principal:
className : UTF-8
name : UTF-8
jwtCount : u8
per JWT:
jwtLength : u32 BE
bytes : UTF-8
The count fields are 16-bit and 8-bit, so a writer can put almost any number in them.
The limits live at the receiving end instead.
BasicInvocationDispatcher fails the request with an IOException past 16 Subjects, past 64 principals in any one Subject, past 4 tokens per Subject, or past 65,536 bytes in any one token.
A hostile peer cannot make the receiver allocate its way to an OutOfMemoryError by lying about a count.
One condition gates the entire block.
Version 0x02 is written only when there is a serialized reducing AccessControlContext to send, and there is none without an authorization SecurityManager installed.
Stock Java 24 has no such SecurityManager, so a client running on it falls back to version 0x01 or 0x00 and no user Subject crosses at all.
Multi-Subject dispatch needs DirtyChai on both ends.
On arrival, Subject.current() returns the first Subject bound via callAs and nothing else.
No user Subject hides in the AccessControlContext as a fallback, and getSubject(acc) is a deprecated shim that ignores its argument and calls current().
Service code that wants the whole chain asks for it explicitly, through the ClientUserSubject accessors from Part 3.
Nothing the Client Says Is Trusted
In that wire format, a principal is a class name and a name, both UTF-8, both chosen by the caller.
Nothing on the wire stops a compromised intermediate from writing net.jini.security.jwt.JwtPrincipal and group:admins into those two fields.
The receiver answers that it never instantiates what the wire asks for.
BasicInvocationDispatcher holds a fixed allowlist of four principal types, constructors resolved once at class-load time:
javax.security.auth.x500.X500Principaljavax.security.auth.kerberos.KerberosPrincipalau.net.zeus.jgdms.spiffe.SpiffePrincipalnet.jini.security.jwt.JwtPrincipal
Any other class name produces a RemotePrincipal placeholder, which carries the strings and matches no policy grant.
Class.forName is never called on a remote-supplied name, which closes a cheap DoS vector: an attacker who can make a server load arbitrary classes can make it contend on the class-loading lock for nothing.
The source comment states the boundary exactly:
The allowlist (not the classloader) is the security boundary.
Reconstructed Subjects are read-only.
Nothing downstream can add a principal to a Subject that arrived from the network.
They are also built as UserSubjects and never as WorkerSubjects, because an identity that arrived over the network is a remote user, not this machine’s own workload.
That settles which types can appear.
It does not, on its own, make sub:alice@example.org true.
Raw JWTs ride alongside the principals for that reason, and a connection-level cache verifies each token at most once per validity window, so a JWKS lookup is amortised across the token’s lifetime.
The default verifier does less than its name suggests.
DefaultJwtVerifier checks exp and iat, then stops.
It performs no JWKS signature verification, since that would put an outbound network call on the dispatch path.
Full OIDC verification is one setJwtVerifier call before export, and any deployment that authorizes on wire-asserted user identity needs to make it.
Why Any of This Is Reflective
Both ends of the protocol reach for methods that standard Java does not declare.
The client needs Subject.currentAll() to collect every user Subject currently in scope, so that every layer of a delegation chain goes on the wire.
The server needs Subject.callAs(Callable, UserSubject…), which binds a whole set of Subjects in one operation instead of one at a time.
Neither is part of the OpenJDK API, so neither can appear as a direct call in code that still has to compile there.
That is a build concern, not a runtime one. Part 1 already ruled out deploying JGDMS on stock OpenJDK. Keeping it compilable there buys an ordinary developer loop: write a service, run its tests with security disabled, keep the toolchain standard. That is worth an indirection.
The indirection reaches further than the method lookup, and that is the part I underestimated.
UserSubject does not exist on a stock OpenJDK, so no main source file may name it.
Not as an import, not as UserSubject.class, and not as UserSubject[].class either.
The array literal is the one that catches people out, because it is still a compile-time reference to its element type.
The parameter type therefore has to be built at runtime, before the method it identifies can be looked up:
Class<?> us = Class.forName("javax.security.auth.UserSubject"); (1)
Class<?> usArray = Array.newInstance(us, 0).getClass(); (2)
Subject.class.getMethod("callAs", Callable.class, usArray); (3)
| 1 | Throws on a stock OpenJDK, where the class is absent |
| 2 | Yields UserSubject[].class without writing UserSubject[].class |
| 3 | getMethod matches parameter types exactly, so Subject[].class would find nothing here |
On DirtyChai, Subject is sealed and permits WorkerSubject and UserSubject, and the only public multi-argument callAs takes UserSubject….
Ask for the supertype array, and the lookup misses every time.
Both steps now live in one class, UserSubjectSupport, instead of being repeated at each call site.
The same constraint applies to more than one class, hence a whole package is split between the two projects: types that migrated into DirtyChai are still carried by JGDMS, so that a build on a stock OpenJDK keeps working.
A missed lookup has two possible causes, and they are not equivalent.
UserSubjectabsent-
Stock OpenJDK. Single-identity binding is the correct behaviour on that runtime; hence the fallback is silent, and the method handle stays
null. UserSubjectpresent, signature absent-
DirtyChai of the wrong vintage. Falling back here would run every request on the first user identity and discard the rest, which narrows the authorization context without anyone having asked for it. The class initializer throws instead, and names the signature it expected.
One trap survives all of it, because the obvious workaround is wrong.
Where a set cannot be bound at once, only the first Subject takes effect, and nesting single-Subject callAs calls to make up the difference does not help.
Each inner call shadows the outer one, and Subject.current() afterwards reports the innermost Subject alone.
Binding a set is a different operation from binding its members in sequence, which is precisely why the varargs form exists.
Identity Across a Thread Boundary
A ScopedValue binding does not cross into a thread you spawn.
That is the design of ScopedValue, and the dispatcher documents the consequence in a comment: a virtual thread started inside a service method inherits the server’s worker identity from the AccessControlContext, not the caller’s user identity.
Submit to a bare ExecutorService, and the user is gone by the time the task runs.
No exception, no warning, just a task executing as the machine.
SubjectAwareExecutor in net.jini.security closes that gap by wrapping any executor:
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
ExecutorService safePool = new SubjectAwareExecutor(pool); (1)
safePool.submit(() -> doSensitiveWork()); (2)
| 1 | Wraps the delegate; submit, execute, invokeAll, and invokeAny all go through the capture |
| 2 | Subject.current() inside the task is the identity that was active at submission |
At submission time, it captures the current Subject[] and the SecurityContext returned by Security.getContext().
On the worker thread, it runs AccessController.doPrivileged with the captured context, and SecurityContext.wrap re-establishes the Subjects inside it.
SPIFFE worker principals need no handling of their own: they sit in the ProtectionDomains of the captured context and propagate with it.
The class never passes a WorkerSubject to callAs.
Every Party Checked at Settlement
A distributed transaction outlives the call that opened it.
SettleTransactionPermission, an AccessPermission with target names "commit", "abort", and "settle", extends the identity model to that longer lifetime.
TxnManagerImpl captures the full Subject[] of each participant when it joins, the transport Subject and every user Subject that arrived with it.
At commit or abort, checkAllParticipantsPermission walks the captured sets, builds a throwaway ProtectionDomain from each `Subject’s principals, and checks the permission against it.
The rule is unanimity, not a threshold.
Every captured Subject must hold the permission individually, or settlement fails.
That is the opposite of the quorum in Part 2, where a majority of analysis engines is enough to issue a verdict.
Two limits on that guarantee.
The check does not require the original parties to be present at settlement: a single caller can commit, provided every Subject captured at join time is authorized.
And it does not apply to the manager’s own recovery path.
When SettlerTask settles an abandoned transaction, there is no remote caller; checkClientPermission says so, and the check is skipped.
The transaction manager settles on its own authority there, as it did before the check existed.
Conclusion
The chain crosses the wire as principals and tokens, bounded by an allowlist the caller cannot extend.
SubjectAwareExecutor keeps it when the work moves to another thread.
TxnManagerImpl checks every party that ever joined before a transaction settles.
None of that decides what Alice is actually allowed to do.
That is the policy stack, and I’ll cover its three layers and why no single party can escalate its own privileges in the series' next part.