Skip to content

JEP 500: Final Means Final – Deep Reflection in Java 26

A steel padlock against a purple background; at the right-hand junction of shackle and body, a glowing weld spot fuses the shackle to the body, sparks flying to the right

Imagine you start an application, and 2 + 2 suddenly yields 5. No typo, no broken compiler – three lines in a third-party library have secretly rewritten the cached Integer object 4 to 5. Via deep reflection, through a final field inside the Integer class that was supposed to be immutable.

That’s a neat party trick. It becomes dangerous when the target isn’t Integer.value but, for example, the permissions of the currently logged-in account.

This is exactly where JEP 500: Prepare to Make Final Mean Final comes in. It is part of Java 26 (released in March 2026) and starts to deliver on a promise more than 20 years old: a final field should really be final – not just a hint to everyone reading the source code, but a guarantee that the JVM keeps.

In this article, you will learn:

  • How a harmless dependency update can bypass your access control via deep reflection
  • What JEP 500 does about it and which options let you control its behavior
  • Which fields JEP 500 does not protect
  • Why the OpenJDK team is locking down final fields: integrity by default and performance
  • How to find out whether your application mutates final fields
  • What you should do before the ban becomes the default in a future Java version

You can find the demo code for this article in two GitHub repositories: the application and the attacking library.

How Deep Reflection Overwrites Final Fields

The 2 + 2 = 5 trick consists of three lines:

Field value = Integer.class.getDeclaredField("value");
value.setAccessible(true);
value.set(4, 5);

Integer.value is private and final. setAccessible(true) overrides both at once – the encapsulation provided by private and the immutability provided by final. After that, set() assigns a new value to the final field, even though the language rules say this should be impossible.

Since Java caches and reuses the Integer 4, from now on every calculation whose result is boxed returns the manipulated object – and thus the value 5. Integer sum = 2 + 2 therefore yields 5, while int sum = 2 + 2 still yields 4.

Since Java 16, this trick requires --add-opens java.base/java.lang=ALL-UNNAMED, because the JDK classes have lived in named modules since Java 9, and there they are protected by strong encapsulation.

Classes on the classpath, on the other hand, don’t get this protection. That’s where – unless you use the module system – all of your application’s code lives (including frameworks like Spring), and all packages on the classpath are open: any code may access their non-public classes, methods, and fields via deep reflection – exactly what --add-opens has to explicitly allow for a JDK package (for java.lang in the example above).

For your own classes and those of your libraries, this means: final has been no guarantee so far. Any class could overwrite a final field – without a warning and without a log entry.

No Exploit Needed – Just a Dependency

The Integer trick is a gimmick. The real problem shows up when third-party code overwrites a final field in your application.

To demonstrate this, here is a small Spring Boot application with a login and a protected admin area. The controller secures /admin via method security – only users with the ADMIN role can access this area:

@GetMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public String admin() {
  return "admin";
}

We log in as alice – a user without roles – and call /admin. The result is what we expect:

HTTP 403 Forbidden

Spring Security’s protection works. So far, so good.

The application uses a small observability library that measures how long each request takes – io.metricstack:request-timing in version 1.0. A timing tool, entirely unsuspicious.

Then a patch update comes out: 1.0 → 1.0.1. The kind of patch release you wave through without thinking – or let Dependabot wave through automatically.

Version 1.0.1 registers a servlet filter via Spring Boot auto-configuration (shown in the following listing without imports and logger declaration). It still measures the request duration – but first calls the method collectRequestContext():

public class RequestTimingFilter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response,
      FilterChain chain) throws IOException, ServletException {
    long startNanos = System.nanoTime();

    // runs after authentication, before the @PreAuthorize check
    collectRequestContext();

    try {
      chain.doFilter(request, response);
    } finally {
      long micros = (System.nanoTime() - startNanos) / 1_000;
      String uri = ((HttpServletRequest) request).getRequestURI();
      log.info("request {} completed in {} µs", uri, micros);
    }
  }

  private void collectRequestContext() {
    Authentication authentication =
        SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null
        || !authentication.isAuthenticated()
        || authentication instanceof AnonymousAuthenticationToken) {
      return;
    }

    try {
      List<GrantedAuthority> elevated =
          List.of(new SimpleGrantedAuthority("ROLE_ADMIN"));
      Field authorities =
          AbstractAuthenticationToken.class.getDeclaredField("authorities");
      authorities.setAccessible(true);
      authorities.set(authentication, elevated);
    } catch (ReflectiveOperationException e) {
      // under deny, set() throws an IllegalAccessException
      log.error("context enrichment skipped", e);
    }
  }
}

doFilter() measures how long the request takes and writes the time to the log – exactly what the library promises. Before the filter passes the request on with chain.doFilter(), however, it calls collectRequestContext().

Because the auto-configuration sets no order, the filter runs after Spring Security’s filter chain: after Spring Security has put the logged-in user into the SecurityContext, but before the request reaches the @PreAuthorize check. At exactly this moment, collectRequestContext() overwrites the final field authorities of the user’s authentication token with a list containing ROLE_ADMIN. If overwriting fails, the catch block logs the exception, and the request continues without the privilege escalation.

The following diagram shows the four stations a request to /admin passes through – with the filter from request-timing 1.0.1 highlighted in yellow:

Four boxes stacked vertically, connected by downward arrows: the Spring Security filter chain, which puts alice’s token into the SecurityContext; the RequestTimingFilter from request-timing 1.0.1, highlighted in yellow, which overwrites the final field authorities in alice’s token with ROLE_ADMIN; method security, whose @PreAuthorize check finds ROLE_ADMIN; finally WebController.admin(), which returns the admin page for alice with HTTP 200 OK
The filter sits between authentication and authorization

After upgrading the library, we log in as alice again and call /admin:

HTTP 200 OK

Without changing a single line of application code. Without any command-line option. Simply by upgrading a library – a timing tool that has nothing to do with Spring Security, nothing to do with authentication, nothing to do with any security feature.

This shows: there is no dependency on the classpath that would be “too unimportant” to be dangerous.

Why This Isn’t a Made-Up Scenario

An attacker doesn’t need a vulnerability in your application, just a way onto your classpath – a supply chain attack. And such attacks have happened again and again in recent years:

  • event-stream (2018): A popular npm package changed maintainers; the new version smuggled in code that targeted the Bitcoin wallets of the Copay app.
  • ua-parser-js (2021): The widely used npm package was compromised and briefly distributed crypto miners and password stealers.
  • xz utils (2024): Over months, a backdoor was slipped into the Linux compression tool, which almost made it into the major distributions.

A typical Java application has dozens to hundreds of JARs on the classpath – direct dependencies, transitive dependencies, dependencies of dependencies. Nobody checks them all. It’s enough for one of them to have changed maintainers.

What JEP 500 Does

As of Java 26, three conditions apply to Field.set() on a final field. Writing is only allowed if

  1. setAccessible(true) succeeded,
  2. the package of the declaring class is open to the caller’s module, and
  3. final field mutation is enabled for the caller’s module.

Condition 1 has existed since Java 5, when the ability to change final fields was introduced in the first place.

Condition 2, that a package must be open to the caller’s module, is the encapsulation rule from the module system (relaxed strong encapsulation since Java 9, strong encapsulation since Java 16), which you control via --add-opens. What’s new is that, as of Java 26, Field.set() additionally checks it itself.

And this is the crucial point: for fields in classes on the classpath, condition 2 is automatically met. Everything on the classpath lives in the unnamed module, and that module is open to everyone. This is exactly why the attack on the Spring application went unnoticed: setAccessible(true) and the package opening were never an obstacle.

Condition 3, on the other hand, has only existed since Java 26, and it is the one that takes effect. If final field mutation isn’t enabled for the caller’s module, the JVM issues a warning when writing:

WARNING: Final field authorities in class
  org.springframework.security.authentication.AbstractAuthenticationToken has
  been mutated reflectively by class
  io.metricstack.requesttiming.RequestTimingFilter in unnamed module @…
  (file:/…/request-timing-1.0.1.jar)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final
  field mutation is enabled

And with that, an attack that previously left no trace suddenly shows up in the log. The warning shows the overwritten field (authorities), the class that declares it (AbstractAuthenticationToken), the writing class (RequestTimingFilter) – and the JAR with its full path. The warning alone is already a win.

--enable-final-field-mutation and --illegal-final-field-mutation

Two command-line options control the new behavior:

With --enable-final-field-mutation, you specify who may change final fields: --enable-final-field-mutation=ALL-UNNAMED enables final field mutation for all classpath code, --enable-final-field-mutation=M1,M2 for specific modules. Enabled modules no longer get a warning. For executable JARs (java -jar), you can also use the manifest attribute Enable-Final-Field-Mutation: ALL-UNNAMED.

What the JVM does on an illegal final field mutation – i.e., one not explicitly enabled by --enable-final-field-mutation – is controlled by the option --illegal-final-field-mutation. It is modeled on the former --illegal-access (JEP 261, until Java 16) and on --illegal-native-access (JEP 472):

OptionEffect
--illegal-final-field-mutation=allowFinal fields are changed without a warning (behavior before Java 26).
--illegal-final-field-mutation=warnOn the first write to a final field by a module, a warning appears – at most one per module (default as of Java 26).
--illegal-final-field-mutation=debugOn every write to a final field, a warning and a stack trace appear.
--illegal-final-field-mutation=denyField.set() throws an IllegalAccessException.

warn is the default as of Java 26 and remains so in Java 27. In a future version, deny will become the default, and allow will be removed. Anyone who still wants to mutate final fields then will have to enable it explicitly via --enable-final-field-mutation.

The Same Attack with deny

You can enable the future behavior today. If we start the application with --illegal-final-field-mutation=deny, Field.set() throws an IllegalAccessException. The catch block in collectRequestContext() catches it and lets the request continue without the privilege escalation – so the application keeps working, only the attack doesn’t:

HTTP 403 Forbidden

alice no longer has admin rights because the final authorities field of her authentication token could not be overwritten. Final now means final.

What JEP 500 Does Not Protect

JEP 500 applies exclusively to final instance fields. This leaves out two kinds of fields:

Static final fields: Field.set() refuses to assign static final fields in every Java version – even after setAccessible(true).

Non-final fields: At this point, a common objection is: “Then you just pick a non-final field.” That’s true – and JEP 500 doesn’t prevent it either. Non-final state remains open to classpath code.

But this isn’t a security hole, it’s your starting point: you decide which fields JEP 500 protects – namely all the ones you make final. The section Prevention below shows how to use this for security-relevant state.

Why All This?

JEP 500 is motivated by two goals: security and performance. You have seen the security aspect – it is part of a bigger plan.

Integrity by Default: The Plan Behind JEP 500

JEP 500 isn’t a one-off step but part of a long-term plan that the OpenJDK team is pursuing under the name “Integrity by Default”: in the future, every JDK feature that can undermine the integrity of the platform is to require the explicit consent of whoever runs the application – a library must not simply grant that consent to itself.

The same choreography in three steps plays out again and again:

  1. The JVM warns, and anyone who wants to can switch on the ban in advance (opt-in).
  2. The ban becomes the default, and an option brings back the old behavior (opt-out).
  3. Finally, the opt-out option is removed, and only what you explicitly enable is allowed.

One gap after another has been and is being closed this way:

  • Java 9: The module system (JEP 261) and the encapsulation of internal APIs (JEP 260).
  • Java 16/17: Strong encapsulation becomes the default (JEP 396, JEP 403) – deep reflection into JDK internals has required --add-opens ever since.
  • Java 21: Warning when agents, e.g., profilers, are loaded dynamically (JEP 451).
  • Java 23/24: Deprecation of and warnings for the memory-access methods in sun.misc.Unsafe (JEP 471, JEP 498).
  • Java 24: Warning on JNI use (JEP 472).
  • Java 26: Final means final (JEP 500).

Strong encapsulation shows all three steps: from Java 9 to 15, the JVM warned about deep reflection into JDK internals with “illegal reflective access … will be denied in a future release”, and --illegal-access=deny switched on the ban in advance. In Java 16, access was denied by default; --illegal-access=permit brought back the old behavior. Since Java 17, this option no longer exists, and only --add-opens still opens individual packages explicitly.

JEP 500 continues the same choreography for final fields.

Performance: Constant Folding

The performance gain that JEP 500 holds out comes from an optimization called constant folding: if a value is fixed, it isn’t re-read or recomputed on every access but written directly into the code as a constant – by the javac compiler into the bytecode, by the JIT compiler into the machine code. This happens on two levels:

  • javac itself replaces true compile-time constants – final primitives and strings with a constant initializer, usually static final – with their value in the bytecode. If MAX_USERS is declared as static final int MAX_USERS = 100, then, for example, MAX_USERS * 2 appears as 200 in the class file – the field is no longer read at all.
  • HotSpot at runtime goes further: when the JIT compiler translates frequently executed code, it may write the value of a final field into the machine code as a constant, provided it trusts the field – that is, it may assume that the field’s value never changes after initialization. This also applies to instance fields that aren’t compile-time constants.

So far, the second level only works to a limited extent. HotSpot already treats static final fields as constants – except for System.in, System.out, and System.err. As long as HotSpot cannot otherwise trust final, however, it may only treat a few final instance fields as constants – namely those in records, hidden classes, and some JDK packages.

If it had written a value that isn’t truly final into the machine code and someone changed the field via reflection, the compiled machine code would keep calculating with the old value – and the result would simply be wrong. Only when final really is final can constant folding be used across the board.

Am I Affected – and What Can I Do?

Code that mutates final fields via deep reflection is often not in your application but in a library.

Finding Affected Code

Three approaches show you whether and where this happens:

  • As of Java 26, the answer is already in the log. Because warn is the default, the first mutation per module shows up as a warning anyway – with field, class, and JAR.
  • --illegal-final-field-mutation=debug prints a warning with a stack trace for every mutation – this also finds the places that the one-time warning per module swallows.
  • JDK Flight Recorder records the event jdk.FinalFieldMutation for every mutation, including the declaring class, field name, and stack trace.

What to Do When You Find Something

If you have found an affected place, you have three options:

  • update the library to a version that does without final field mutation (many libraries are already working on it – check the release notes);
  • report it to the library’s maintainers;
  • or – as a last resort – explicitly allow the mutation via --enable-final-field-mutation.

Prevention: Make Security-Relevant State final

The more important part, however, is prevention: Make security-relevant state final – roles, keys, limits, flags. Whatever is final, JEP 500 protects – as of Java 26 with --illegal-final-field-mutation=deny and by default in the future – even against code on the classpath that the module system’s encapsulation leaves open.

You are on the safe side with records: their fields cannot be overwritten via reflection at all – regardless of any option.

Alternatively – or additionally – you can encapsulate the state via the module system: in a module of your own whose package you don’t open via opens, code from other modules or from the classpath can’t get at it in the first place.

These are two independent controls: the module system’s encapsulation governs where reflection may reach, --enable-final-field-mutation from JEP 500 governs who may overwrite final fields. Security-relevant state is best protected when it is final and lives in a module that doesn’t open its package.

What Does This Mean for Libraries?

Anyone who maintains a library that mutates final fields should act instead of making the library’s users set --enable-final-field-mutation. There are two cases to distinguish.

Serialization libraries are the legitimate case. They must populate final fields during deserialization without calling the constructor – just like the JDK’s own serialization. That is what sun.reflect.ReflectionFactory is for: the API provides dynamically generated code that populates an object directly during deserialization, final fields included, and it works without --enable-final-field-mutation.

However, ReflectionFactory only supports classes that implement java.io.Serializable – it is no back door for arbitrary classes. That is intentional: the JVM may assume that final fields in all non-serializable classes – the vast majority – are permanently immutable and can optimize them accordingly. For serializable classes, on the other hand, it must expect final fields to be set during deserialization – after creation and without a constructor.

All others – frameworks for dependency injection, mocking, and testing – have to change their architecture and move away from mutating final fields. Most DI frameworks have long discouraged injecting final fields and recommend constructor injection instead; mocking and testing tools need approaches that work without deep reflection on other classes’ final fields. --enable-final-field-mutation should remain the last resort there, not the default answer.

Summary

For over 20 years, final in Java was merely a declaration of intent: any class of an application could overwrite a final instance field via deep reflection – silently. For a supply chain attack, a harmless-looking dependency that rewrites a permission field somewhere deep in the classpath was enough.

JEP 500 turns this into a guarantee in Java 26. At first only as a warning, which at least makes the attack visible; with --illegal-final-field-mutation=deny already today as a hard boundary; and in a future version as the default.

For you, this means two things: check with --illegal-final-field-mutation=debug or Flight Recorder whether your application mutates final fields – and consistently make security-relevant state final so that the guarantee of JEP 500 works for you.

Did this article save you time? Then I’d be happy if you invested a minute of it in a review on my ProvenExpert profile. Your feedback shows me that the work on these articles pays off.

👉 Leave a review

This subject in your own code?

You have read the article – in the training your team works with it. Over 2 days we go through the subjects on your own projects instead of constructed examples.

Hands-on, easy to understand, and directly applicable to your day-to-day project work. Instead of theory, I teach principles that help you write code that is better, more maintainable, and more performant in the long run.

Java 25 TrainingSee all trainings

Become a Better Java Developer

My free newsletter keeps you ahead. Modern Java: new versions & features, performance, and JVM insights – once a month.

Search