Patch a Java Library Method with Quarkus Shim
Build a small Quarkus service, replace one fictional SDK method, and prove the patched and unpatched behavior with tests.
I first noticed Quarkus Shim in the Quarkus extension list. I’d never used it before, and “patch Java classes at build time” sounded interesting. In particularly now that everyone is talking about CVS and how to quickly patch production in case it’s needed. So I opened the project to see what it really does. Join me on my little exploration.
It turns out that Quarkus Shim can add code before or after a method, wrap a method, or replace its body. The class can be part of your application or come from a dependency. Quarkus makes the change during augmentation, which is its build-time analysis and code generation step. The changed class is already part of the JVM or native application you build. There is no Java agent running later.
That made me think about temporary fixes. Sometimes a dependency has a small bug, but the full upgrade takes longer than the current release allows. I do not think this is meant as anything more than a temporary solution though. A shim can provide a temporary fix in times of need. It is a bandaid and not a cure. Even if you use it, you will still have to upgrade the hole dependency later.
Let’s also look at this from the initialy mentioned security angle more. How can I prove the behavior before and after the patch? Will the software bill of materials (SBOM) still show the original dependency? And how do I make sure the patch expires? I built the example to answer exactly those questions.
I use a fictional access-policy SDK, so there is no real vulnerable library and no CVE. The SDK has one simple bug: it allows every decision except DENY. This means a timeout, a new status, or a spelling error can allow access.
What We Build
The project has two Maven modules. vendor-policy simulates the vendor JAR access-policy-sdk:1.0.0. policy-service is a Quarkus REST application that uses the JAR and applies the shim.
The application exposes GET /authorization/{decision}. Once the shim is active, only ALLOW returns 200 OK. Values such as DENY and REVIEW return 403 Forbidden.
While we build the application, the tests and CI checks will prove the following:
The patched application allows an explicit
ALLOWThe patched application denies an unknown decision
The same unknown decision passes when Shim processing is disabled
The packaged fast-jar keeps the patched behavior
The transformed-class dump contains the replacement call
The SBOM still records the original vendor dependency
The most important demo is the third one. We run the same case with Shim disabled and expect the unsafe result. This is our negative control that proves the shim caused the change. Without this check, the HTTP tests could pass because someone added validation in another place.
What You Need
I built the example with Quarkus 3.37.3, Quarkus Shim 0.2.0, and Java 21. Quarkus Shim 0.2.0 was released in July 2026. You need:
Java 21 installed
Quarkus CLI 3.37.x
curlA POSIX shell for the pipeline checks
About ☕️☕️ (not that much, even if it is a somewhat security related topic)
Java and the Maven wrapper are enough for this build.
Create the Maven Project
I start with a plain Quarkus application. You can follow along and copy and paste or just clone my mono-repo that has the example too:
quarkus create app com.themainthread:quarkus-shim-secure-pipeline \
--extension=rest-jackson,cyclonedx \
--java=21 \
--no-codeREST Jackson gives us the JSON endpoint. CycloneDX writes an SBOM when Quarkus packages the application. I add Quarkus Shim separately because it is a Quarkiverse extension and I want to set its version myself. You can read more about CycloneDX and Quarkus in an earlier article of mine:
Next, we move the generated application into a child module. This keeps the “vendor code” in its own JAR, just like a real dependency:
cd quarkus-shim-secure-pipeline
mkdir policy-service vendor-policy
mv src policy-service/
mv pom.xml policy-service/pom.xmlCreate a new parent pom.xml at the project root:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.themainthread</groupId>
<artifactId>quarkus-shim-secure-pipeline-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>vendor-policy</module>
<module>policy-service</module>
</modules>
<properties>
<compiler-plugin.version>3.15.0</compiler-plugin.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.version>3.37.3</quarkus.platform.version>
<quarkus-shim.version>0.2.0</quarkus-shim.version>
<surefire-plugin.version>3.5.6</surefire-plugin.version>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>I put the Quarkus plugin in pluginManagement so Maven can find quarkus:dev from the project root. Maven skips the goal for the vendor JAR and starts policy-service.
Update the parent coordinates at the top of policy-service/pom.xml:
<parent>
<groupId>com.themainthread</groupId>
<artifactId>quarkus-shim-secure-pipeline-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>policy-service</artifactId>
<packaging>quarkus</packaging>Keep the generated Quarkus BOM and build-plugin configuration in that file. Then add the vendor JAR and Quarkus Shim:
<dependency>
<groupId>com.themainthread.vendor</groupId>
<artifactId>access-policy-sdk</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.shim</groupId>
<artifactId>quarkus-shim</artifactId>
<version>${quarkus-shim.version}</version>
</dependency>The generated POM already has REST, CycloneDX, Quarkus JUnit, and RestAssured. We can leave those entries as they are.
Add the Fictional Vendor Bug
Create vendor-policy/pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.themainthread</groupId>
<artifactId>quarkus-shim-secure-pipeline-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<groupId>com.themainthread.vendor</groupId>
<artifactId>access-policy-sdk</artifactId>
<version>1.0.0</version>
<name>Fictional vendor access policy SDK</name>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
</plugin>
</plugins>
</build>
</project>Now add vendor-policy/src/main/java/com/themainthread/vendor/LegacyDecisionEngine.java. This class simulates code from a vendor JAR:
package com.themainthread.vendor;
/**
* Simulates a vendor class that we cannot change during an incident.
*/
public final class LegacyDecisionEngine {
private LegacyDecisionEngine() {
}
/**
* Returns whether an upstream policy decision allows access.
*
* @param decision the decision returned by the upstream policy system
* @return {@code true} unless the upstream system explicitly denied access
*/
public static boolean isAllowed(String decision) {
return !"DENY".equalsIgnoreCase(decision);
}
}The method only checks for DENY. Every other value passes, including REVIEW, an empty string, and null.
For an access decision, I want a simple rule: allow ALLOW and reject everything else. We could add the check to our REST resource, but then every other caller has to remember it too. In this example, several callers already use the SDK method.
Add a Small HTTP Endpoint
We need a simple way to call the vendor method and see its result. Create policy-service/src/main/java/com/themainthread/policy/AuthorizationResource.java:
package com.themainthread.policy;
import com.themainthread.vendor.LegacyDecisionEngine;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/authorization")
@Produces(MediaType.APPLICATION_JSON)
public class AuthorizationResource {
@GET
@Path("/{decision}")
public Response authorize(@PathParam("decision") String decision) {
boolean allowed = LegacyDecisionEngine.isAllowed(decision);
DecisionResponse body = new DecisionResponse(decision, allowed);
Response.Status status = allowed ? Response.Status.OK : Response.Status.FORBIDDEN;
return Response.status(status).entity(body).build();
}
public record DecisionResponse(String decision, boolean allowed) {
}
}The endpoint is simple (or say boring :-)). It returns the result from the vendor method, so we can test the same call in dev mode, in JVM tests, and in the packaged application.
Replace the Method at Build Time
Now we can add the patch. Create policy-service/src/main/java/com/themainthread/policy/LegacyDecisionEngineShim.java:
package com.themainthread.policy;
import com.themainthread.vendor.LegacyDecisionEngine;
import io.quarkiverse.shim.Shim;
import io.quarkiverse.shim.ShimReplace;
@Shim(value = LegacyDecisionEngine.class, name = "fail-closed-decision")
public final class LegacyDecisionEngineShim {
private LegacyDecisionEngineShim() {
}
@ShimReplace(method = "isAllowed", paramTypes = String.class)
public static boolean isAllowed(String decision) {
return "ALLOW".equalsIgnoreCase(decision);
}
}@Shim tells Quarkus to change LegacyDecisionEngine. I name this shim fail-closed-decision. This name lets us control this one shim in the configuration. @ShimReplace replaces the original method body with a call to our static method.
The target method is static, so our method only receives the original String argument. If we replaced an instance method, our method would receive the target object first. The other parameters and the return type still have to match.
I set paramTypes = String.class to select the exact overload. If the vendor changes that method signature, Quarkus stops the build. We have to review the changed vendor API before we can use the patch again.
Our method only accepts ALLOW, ignoring case. null, REVIEW, and any new vendor status return false. The method now fails closed.
Quarkus needs to index the vendor JAR before it can validate and change the class. Add policy-service/src/main/resources/application.properties:
quarkus.index-dependency.vendor-policy.group-id=com.themainthread.vendor
quarkus.index-dependency.vendor-policy.artifact-id=access-policy-sdk
quarkus.shim.dump-transformed-classes=trueThe first two properties tell Quarkus to build a Jandex index for the vendor JAR. Jandex is an index of Java classes and annotations. Quarkus uses it during the build.
The last property writes a readable bytecode trace under target/shim/. Quarkus Shim creates the trace with ASM, the Java bytecode library it uses to change the class. We will inspect this file later.
These properties only work during the build. Changing them after you package a fast-jar doesn’t change its classes. You have to build the application again.
The Quarkus Shim documentation also lists two switches. quarkus.shim.enabled=false disables all shims. quarkus.shim.instances."fail-closed-decision".enabled=false disables only our named one. I use the first switch for the negative-control test. Both switches change the build so remeber that they can’t change bytecode that is already running.
Test the Patched Behavior
Now we test the behavior we expect. Add policy-service/src/test/java/com/themainthread/policy/AuthorizationResourceTest.java:
package com.themainthread.policy;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class AuthorizationResourceTest {
@Test
void allowsExplicitAllowDecision() {
given()
.when().get("/authorization/ALLOW")
.then()
.statusCode(200)
.body("decision", is("ALLOW"))
.body("allowed", is(true));
}
@Test
void deniesExplicitDenyDecision() {
given()
.when().get("/authorization/DENY")
.then()
.statusCode(403)
.body("decision", is("DENY"))
.body("allowed", is(false));
}
@Test
void failsClosedForUnknownDecision() {
given()
.when().get("/authorization/REVIEW")
.then()
.statusCode(403)
.body("decision", is("REVIEW"))
.body("allowed", is(false));
}
}These tests cover the new rule. But they don’t prove that the shim changed the vendor method. The same tests could pass if someone added the check inside the REST resource.
Add the Negative Control
Now I add the negative control. A Quarkus test profile can rebuild the test application with different build-time settings. Create UnpatchedShimProfile.java in the same test package:
package com.themainthread.policy;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
public class UnpatchedShimProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("quarkus.shim.enabled", "false");
}
}Then add UnpatchedBehaviorTest.java:
package com.themainthread.policy;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
@QuarkusTest
@TestProfile(UnpatchedShimProfile.class)
class UnpatchedBehaviorTest {
@Test
void provesTheVendorBehaviorFailsOpenWithoutTheShim() {
given()
.when().get("/authorization/REVIEW")
.then()
.statusCode(200)
.body("decision", is("REVIEW"))
.body("allowed", is(true));
}
}This test expects the unsafe result. It passes only when the original LegacyDecisionEngine allows REVIEW. The earlier test expects 403 with the shim enabled. Together, the tests prove that the shim changes the method.
This test also helps us remove the patch later. If version 1.1.0 of the fictional SDK fixes the problem, the negative control will receive 403 and fail. That failure tells us that we no longer need the shim. We can remove the shim, its exception record, and the extra tests.
Run the JVM suite:
./mvnw testThe summary should report:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe build log also shows both augmentation paths:
Shim processing is disabled (quarkus.shim.enabled=false); @Shim declarations are ignored
Shim: com.themainthread.vendor.LegacyDecisionEngine#isAllowed [replace]
<- com.themainthread.policy.LegacyDecisionEngineShim#isAllowedTest the Packaged Application
So far, we only tested the Quarkus test application. I also want to test the packaged fast-jar because that is what we would deploy. Add AuthorizationResourceIT.java:
package com.themainthread.policy;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class AuthorizationResourceIT extends AuthorizationResourceTest {
}Run the full build:
./mvnw verifyExpected summaries:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe first line covers the two Quarkus test profiles. The second line covers the packaged application. The Quarkus post about Shim also says that it works with native executables. If you deploy a native application, run the integration test with -Dnative too.
Inspect the Transformed Class
The HTTP tests pass. Now I want to see what Quarkus changed inside the class. Open the bytecode dump:
sed -n '/isAllowed/,/MAXLOCALS/p' \
policy-service/target/shim/com.themainthread.vendor.LegacyDecisionEngine.txtExpected output:
public static isAllowed(Ljava/lang/String;)Z
ALOAD 0
INVOKESTATIC com/themainthread/policy/LegacyDecisionEngineShim.isAllowed (Ljava/lang/String;)Z
IRETURN
MAXSTACK = 2
MAXLOCALS = 1The original comparison with DENY is gone. The method now loads the argument, calls LegacyDecisionEngineShim.isAllowed, and returns the result. Exactly the change I expected.
Keep the Vendor Dependency in the SBOM
The CycloneDX extension writes policy-service/target/quarkus-run-cyclonedx.json during verify. Check that it still lists the vendor dependency:
grep -n 'access-policy-sdk' \
policy-service/target/quarkus-run-cyclonedx.jsonThe output still lists access-policy-sdk:1.0.0. This is correct. The shim changes the bytecode in our application. It doesn’t change the Maven coordinates, license, release history, or vulnerability data of the dependency. With a real dependency, a scanner may still report a known issue even when the shim blocks the affected method.
Keep the finding visible and add an exception with an expiry date. Link the exception to the shim, its tests, the method signature, the owner, and the planned upgrade. The Quarkus CycloneDX guide explains that this SBOM records both the Quarkus build output and its Maven dependencies.
I would personally keep the generated SBOM as a build artifact and leave the optional /.well-known/sbom endpoint disabled because a public endpoint would expose the dependency versions. If runtime scanners need this endpoint, put it on a protected management interface.
Give the Shim an Owner and Expiry Date
The patch works now. What Quarkus Shim does not help with is controlling the patch lifecycle. When it’s not visible in CycloneDX and Shim basically keeps it in the application forever, you will have to create a shim observation process for your applications. You can add all this to a simple yaml and build some scripts around it.
For example, add a shim-policy.yaml at the project root:
id: SHIM-001
status: temporary
owner: application-security
introduced-on: 2026-07-22
expires-on: 2026-10-31
dependency: com.themainthread.vendor:access-policy-sdk:1.0.0
target-class: com.themainthread.vendor.LegacyDecisionEngine
target-method: isAllowed(java.lang.String)
shim-class: com.themainthread.policy.LegacyDecisionEngineShim
reason: Unknown policy decisions default to allow in the fictional vendor SDK.
removal-condition: Upgrade to a vendor release that denies unknown decisions, run the suite without the shim, then delete SHIM-001.And create a simple scripts/check-shim-policy.sh:
#!/bin/sh
set -eu
policy_file="${1:-shim-policy.yaml}"
if [ ! -f "$policy_file" ]; then
echo "Missing shim policy: $policy_file" >&2
exit 1
fi
expires_on=$(sed -n 's/^expires-on: //p' "$policy_file")
target_class=$(sed -n 's/^target-class: //p' "$policy_file")
target_method=$(sed -n 's/^target-method: //p' "$policy_file")
if [ -z "$expires_on" ] || [ -z "$target_class" ] || [ -z "$target_method" ]; then
echo "Shim policy must declare expires-on, target-class, and target-method" >&2
exit 1
fi
today_number=$(date -u +%Y%m%d)
expires_number=$(printf '%s' "$expires_on" | tr -d '-')
if [ "$today_number" -ge "$expires_number" ]; then
echo "Shim policy expired on $expires_on" >&2
exit 1
fi
echo "Shim policy is active until $expires_on"Make the script executable and run it:
chmod +x scripts/check-shim-policy.sh
./scripts/check-shim-policy.shExpected output:
Shim policy is active until 2026-10-31I just use a simple parser here because this example has one small file. If your organization already has a risk register, keep the same fields there and let CI call its API.
Verify the Build Evidence
The policy check covers the owner and the expiry date. Now we check the files from the build. Create scripts/verify-build-evidence.sh:
#!/bin/sh
set -eu
shim_dump=$(find policy-service/target/shim -type f -name '*LegacyDecisionEngine*.txt' -size +0c -print -quit)
sbom=$(find policy-service/target -type f -name '*cyclonedx.json' -print -quit)
if [ -z "$shim_dump" ]; then
echo "No transformed-class dump found for LegacyDecisionEngine" >&2
exit 1
fi
if [ -z "$sbom" ]; then
echo "No CycloneDX SBOM found" >&2
exit 1
fi
if ! grep -Fq 'access-policy-sdk' "$sbom"; then
echo "Vendor dependency is missing from $sbom" >&2
exit 1
fi
echo "Verified transformed class: $shim_dump"
echo "Verified vendor dependency in SBOM: $sbom"This script checks for the bytecode dump and for the same dependency name in the SBOM. Both files should come from the same build.
Put the Controls in GitHub Actions
Finally, to round this up, we could put the same checks into .github/workflows/secure-shim.yml:
name: Secure shim pipeline
on:
push:
branches:
- main
pull_request:
schedule:
- cron: '23 5 * * 1'
permissions:
contents: read
concurrency:
group: secure-shim-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out the exact revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up Java 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Reject missing or expired shim policy
run: ./scripts/check-shim-policy.sh
- name: Test and package the application
run: ./mvnw --batch-mode --no-transfer-progress verify
- name: Verify transformed bytecode and SBOM evidence
run: ./scripts/verify-build-evidence.sh
- name: Preserve review evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: shim-build-evidence
if-no-files-found: error
retention-days: 14
path: |
shim-policy.yaml
policy-service/target/shim/*.txt
policy-service/target/quarkus-run-cyclonedx.jsonThe workflow runs for pushes, pull requests, and every Monday. I added a weekly run because the policy can expire even when nobody changes the code.
Pin each external action to a full commit SHA. GitHub’s secure-use guidance explains that tags can move. The SHA points to the exact action code we reviewed. The comment next to it shows the release version.
The YAML file records the owner. Repository rules enforce the review. Use CODEOWNERS or a similar rule to require that team’s approval when someone changes the shim class, policy file, workflow, or vendor version.
You can also add GitHub dependency review as a separate pull-request job. That job checks changes in the dependency graph and can stop a new vulnerable dependency. The shim job checks the behavior of our patch and its expiry date.
Run It by Hand
Now we have automated tests, but I still like to call the endpoint once by hand. Start the project in dev mode:
./mvnw -pl policy-service -am quarkus:devThe build log lists the replacement, and the Dev UI shows an Applied shims card. In another terminal, send an explicit allow decision:
curl -i http://localhost:8080/authorization/ALLOWExpected response:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{"decision":"ALLOW","allowed":true}Then send the unknown status:
curl -i http://localhost:8080/authorization/REVIEWExpected response:
HTTP/1.1 403 Forbidden
Content-Type: application/json;charset=UTF-8
{"decision":"REVIEW","allowed":false}The manual request thankfully gives us the same result as the JVM test and the packaged test. The negative control shows the old behavior. The bytecode dump shows the new method call. We have now checked the change at the HTTP level and inside the class.
Limits of the Patch
A shim only changes the class in this application build. Other services that use the same JAR keep the old behavior. The Maven repository also keeps the original JAR. This is why the vendor upgrade is still needed and this stay what it is: A way to ship an emergency patch ASAP.
@ShimReplace removes the original method body completely. Any metrics, cleanup, validation, or other side effects in that method also disappear. I use replacement here because the fictional method only returns a boolean. If you still need the original code, use @ShimAround to check the input or change the result. Then test every side effect you need.
Shim can also access private fields and methods, but that connects our code to details inside the vendor class. A public method is easier to review and remove later.
Treat the shim class like other security code. Anyone who changes LegacyDecisionEngineShim can change the authorization result for every caller in this application. Require reviewers for this file and keep the changes small. Another engineer should also be able to rebuild the bytecode dump.
Conclusion
I started this little tutorial because Quarkus Shim appeared in the extension list and I wanted to know what it does. The annotation itself is simple. I spent most of my time proving the change and making sure we can remove it later.



