Skip to main content

JFR In-Process Data Redaction (JEP 536): Safe Flight Recordings in Production

JDK 27’s JEP 536 makes Java Flight Recorder redact command-line arguments, environment variables and system properties before they reach a recording. We plant seven fake secrets, record on JDK 25 and JDK 27, and find that defaults catch three and miss four. Includes custom filters, filter files, the glob and plus-prefix traps, and a CI leak scanner.

A Java Flight Recording is the file you attach to a support ticket, paste into a chat, or hand to a vendor when production misbehaves. Most people think of it as a performance trace. It is also, quietly, a record of exactly how your JVM was started: every command-line argument, every -D system property, and every environment variable. If someone put a database password in any of those places, the password is now in the recording. JDK 27 closes that hole. JEP 536, JFR In-Process Data Redaction, makes the JVM replace sensitive values with [REDACTED] before they reach the recording, and it does this by default. There are very few tutorials for it yet, so this post does what the rest of this blog does: it plants seven fake secrets, records them on JDK 25 and on JDK 27, and shows which ones survive. Spoiler: on defaults, four of seven still leak, and the reasons are instructive.
Versions this was tested against. JEP 536 on openjdk.org/jeps/536: status Closed / Delivered, release 27, component hotspot/jfr, owner Erik Gahlin. JDK 27 reached GA on 2026-09-15 (Java 27 every JEP). Every JDK 27 run below used Eclipse Temurin 27.0.1.0.2+1; the leak baseline used Temurin 25.0.5+8. No Maven, no framework: two small Java files and shell scripts in the redaction module of the companion repo.
If you have not used JFR before, start with Java Flight Recorder and Mission Control: Profiling a Live Spring Boot App in 20 Minutes. This post slots into that workflow at one point: the moment you decide to share a recording.

A recording remembers how your JVM was started

When JFR starts, it writes a handful of one-off events describing the process: which flags it was launched with, which system properties were set, which environment variables existed. They are there so that whoever opens the file can tell how the application was configured. That is genuinely useful, and it is also where the trouble comes from, because configuration is where secrets live. Four event types matter here. jdk.JVMInformation carries the JVM arguments and the Java (program) arguments. jdk.InitialSystemProperty carries every system property. jdk.InitialEnvironmentVariable carries the environment. jdk.StringFlag carries the value of string-typed JVM flags, including the JFR options themselves.
EnvironmentACCESS_TOKEN=… System properties-Ddb-pass=… Command line–dbpassword … JFR startup events JVMInformationInitialSystemPropertyInitialEnvironmentVariableStringFlag dump.jfron disk Vendor support Chat / ticket Shared drive Three inputs, one file, many readers: whatever the JVM was started with travels wherever the recording does.
The diagram shows the shape of the problem: three independent ways a secret gets into a process, one funnel of startup events, one file. The file is the thing that travels, and it travels to people who were never meant to see the password.
Going deeper: what a .jfr file actually is A recording is a binary file of typed events plus constant pools, readable with the jfr tool, JDK Mission Control, or the jdk.jfr.consumer API. The earlier JFR post uses jfr print and jfr summary on exactly these files. Nothing in the format is encrypted or obfuscated, which is why the leak check later in this post can simply search the raw bytes.

See the leak on JDK 25 before you fix anything

The harness plants seven fake secrets. Each name says where it hides: LEAK-ENV-TOKEN-1 is in an environment variable, LEAK-PROP-PASSWORD-2 in a system property, and so on. The program, Workload, does a moment of work and exits; all the interesting content is on its command line. The launcher runs it under env -i, so only the planted values can ever enter a recording and your real environment is never passed through. Source: scripts/common.sh and src/Workload.java.
run_jvm() {
  env -i PATH=/usr/bin:/bin HOME=/nonexistent \
      ACCESS_TOKEN=LEAK-ENV-TOKEN-1 DB_CONN=LEAK-ENV-CONN-5 DEPLOY_REGION=eu-west-1 \
    "$jdk/bin/java" "$@" -Xmx256m \
      -Djavax.net.ssl.keyStorePassword=LEAK-PROP-PASSWORD-2 \
      -Ddb-pass=LEAK-PROP-DBPASS-6 \
      -Dapp.region=mumbai \
      -cp "$BUILD" Workload \
      --dbpassword LEAK-ARG-DBPASSWORD-3 \
      --db-pass LEAK-ARG-DBPASS-7 \
      https://john:[email protected]/login \
      --verbose
}
That is an excerpt of scripts/common.sh. Now record on JDK 25 and look with the standard tool, jfr print. Output from output/01-jdk25-leak.txt:
  jvmArguments = "-XX:StartFlightRecording:filename=/tmp/jfr-redaction/jdk25.jfr -Xmx256m -Djavax.net.ssl.keyStorePassword=LEAK-PROP-PASSWORD-2 -Ddb-pass=LEAK-PROP-DBPASS-6 -Dapp.region=mumbai"
  jvmFlags = N/A
  javaArguments = "Workload --dbpassword LEAK-ARG-DBPASSWORD-3 --db-pass LEAK-ARG-DBPASS-7 https://john:[email protected]/login --verbose"
jdk.InitialEnvironmentVariable {
  key = "ACCESS_TOKEN"
  value = "LEAK-ENV-TOKEN-1"
}
Every secret is there in plain text: the arguments in jvmArguments and javaArguments (and again in the sun.java.command property), the system properties in their own events, the environment variable in its own event. Eyeballing jfr print output is not a test, though, so the repo ships a scanner, src/Scan.java. It reads the file two ways: once as raw bytes (what strings would find) and once as decoded events (what jfr print shows). Result for the same JDK 25 recording, from output/01-jdk25-leak.txt:
LEAK-ENV-TOKEN-1           RAW=LEAK  EVENTS=1
LEAK-PROP-PASSWORD-2       RAW=LEAK  EVENTS=3
LEAK-ARG-DBPASSWORD-3      RAW=LEAK  EVENTS=3
LEAK-URL-PW-4              RAW=LEAK  EVENTS=3
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=LEAK  EVENTS=3
LEAK-ARG-DBPASS-7          RAW=LEAK  EVENTS=3
exit=2
All seven secrets are found in the raw file. That table is the baseline for the rest of the post: RAW=LEAK means the bytes are in the file, and the goal is to turn every line to clean.
Why the raw-byte check matters. jfr print only shows the events you ask for. A secret can hide in an event you did not think to print. Searching the raw file answers the question a reviewer actually asks: is this string anywhere in the file I am about to send?

What JEP 536 changes: redact before the data leaves the process

The JEP adds redaction inside the JVM. Matching values are replaced with the literal text [REDACTED] before the event is written anywhere. Two sub-options of the existing -XX:FlightRecorderOptions flag choose what to redact: redact-key for key-value pairs (environment variables and system properties; the filter matches the name and the value is hidden) and redact-argument for command-line arguments. Matching is case-insensitive, filters are glob patterns with * and ?, filters are separated by semicolons, and the two sub-options are separated by a comma. That is all documented in the JEP; the runs below confirm each rule.
Before: clean up after the fact JVM (raw data) temp files: secrets .jfr file: secrets jfr scrub (manual step) After: JEP 536, redact inside the process JVM (raw data) filters: [REDACTED] temp + .jfr: clean streams: clean too The red boxes are where unredacted data can exist; moving the filter in front of them removes the boxes.
Why does “in-process” matter? Because the older fix, jfr scrub, works on a finished file. Per the JEP, the data is still written to a temporary directory before it reaches the recording, so a crash can leave unredacted data behind, and a recording that is streamed (through FlightRecorderMXBean or RemoteRecordingStream) can carry the raw values off the host. The JEP presents those as the reasons post-processing is not enough; I did not test the crash or streaming cases, so treat that paragraph as JEP-sourced rather than executed.
Going deeper: the alternatives the JEP rejected The JEP lists three. Disabling events (jdk.InitialEnvironmentVariable#enabled=false) works but throws away non-sensitive values you need for troubleshooting, and relies on someone knowing to do it. Regular expressions were rejected because the C++ regex library can throw exceptions, which HotSpot forbids, and java.util.regex would add startup time and need an upcall from the JVM. Off by default was rejected because the person who configures the command line may not know a secret is there, and because someone could later attach Mission Control or run jcmd against a JVM they did not configure. All three are from the JEP text, not from my runs. See openjdk.org/jeps/536.

Turn it on: you already did, and it catches three of seven

The surprise for most people is that there is nothing to turn on. Redaction is the default in JDK 27. Same program, same planted secrets, no redaction flags at all. Output from output/02-jdk27-defaults.txt:
LEAK-ENV-TOKEN-1           RAW=clean EVENTS=0
LEAK-PROP-PASSWORD-2       RAW=clean EVENTS=0
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=LEAK  EVENTS=2
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=LEAK  EVENTS=2
LEAK-ARG-DBPASS-7          RAW=LEAK  EVENTS=2
exit=2
Three secrets are gone from the file: the ACCESS_TOKEN environment variable, the keyStorePassword property, and the --dbpassword argument. Four remain. Here is what jfr print shows for the redacted and the surviving values:
  jvmArguments = "-XX:StartFlightRecording:filename=/tmp/jfr-redaction/default.jfr -Xmx256m [REDACTED] -Ddb-pass=LEAK-PROP-DBPASS-6 -Dapp.region=mumbai"
  jvmFlags = N/A
  javaArguments = "Workload [REDACTED] [REDACTED] --db-pass LEAK-ARG-DBPASS-7 https://john:[email protected]/login --verbose"
jdk.InitialEnvironmentVariable {
  key = "ACCESS_TOKEN"
  value = "[REDACTED]"
}

jdk.InitialEnvironmentVariable {
  key = "DB_CONN"
  value = "LEAK-ENV-CONN-5"
}
Notice what is preserved: -Xmx256m, -Dapp.region=mumbai and --verbose are intact, which is the point of the design. You can still diagnose the process; you just cannot read its secrets. Notice also that --dbpassword and its value are two redactions, because the default argument filter -*password * matches an option and swallows the argument after it.
JDK 25JDK 27 defaultsJDK 27 tuned 1 env ACCESS_TOKENLEAKSredactedredacted2 prop keyStorePasswordLEAKSredactedredacted3 arg –dbpasswordLEAKSredactedredacted4 URL user:pass@hostLEAKSLEAKSredacted5 env DB_CONNLEAKSLEAKSredacted6 prop db-passLEAKSLEAKSredacted7 arg –db-passLEAKSLEAKSredacted Seven fake secrets, three runs. Defaults catch the obvious names; the four that survive are the ones your own naming invented.
The scoreboard is the argument of this section. The middle column is what you get if you upgrade to JDK 27 and do nothing: better, not safe. The four survivors share one trait. Their names (DB_CONN, db-pass, --db-pass) or their shape (a password inside a URL) are not on the default list. The defaults are a list of words, and your naming conventions are not obliged to use them.
Reference: the default filter lists (from the JEP) For redact-key the defaults are: *api*key*, *auth*, *client*secret*, *credential*, *jaas*config*, *passphrase*, *passwd*, *password*, *private*key*, *pwd*, *secret*, *token*. The redact-argument defaults are the same patterns without *auth* (it would match words such as “author”), plus option-and-next-argument forms such as -*password *, -*token * and -*secret *. This list is copied from the JEP page and was not independently executed in full; my runs confirm *token* and *password* in the key list and -*password * in the argument list, and that db-pass matches nothing in either.

Close the gaps: your own filters, and the three ways they go wrong

Custom filters go in the same flag. The obvious first attempt for my four survivors is below. I wrote it this way on purpose, because it is the mistake I would make from memory; output from output/04-jdk27-custom-inline.txt:
-XX:FlightRecorderOptions:redact-key=+db-pass;conn*,redact-argument=+--db-pass *;https://*:*@*

Result:

LEAK-ENV-TOKEN-1           RAW=clean EVENTS=0
LEAK-PROP-PASSWORD-2       RAW=clean EVENTS=0
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=clean EVENTS=0
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=clean EVENTS=0
LEAK-ARG-DBPASS-7          RAW=clean EVENTS=0
exit=2
Six of seven are clean, and LEAK-ENV-CONN-5 is still in the file. The filter conn* did not match the variable DB_CONN, because a glob is anchored to the whole name: conn* means “starts with conn”. To match anywhere, write *conn*. The fix, same output file:
-XX:FlightRecorderOptions:redact-key=+db-pass;*conn*,redact-argument=+--db-pass *;https://*:*@*
LEAK-ENV-TOKEN-1           RAW=clean EVENTS=0
LEAK-PROP-PASSWORD-2       RAW=clean EVENTS=0
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=clean EVENTS=0
LEAK-ENV-CONN-5            RAW=clean EVENTS=0
LEAK-PROP-DBPASS-6         RAW=clean EVENTS=0
LEAK-ARG-DBPASS-7          RAW=clean EVENTS=0
exit=0
All seven clean, exit code 0. Two more traps in the same flag, each with a captured run. The + prefix. Without it, your list replaces the defaults rather than adding to them. Passing only redact-key=db-pass brings back the token and the password. Output from output/07-jdk27-no-plus-replaces-defaults.txt:
[0.127s][warning][jfr,redact] Default redaction filters are replaced. Prepend with '+' to add filters to the
[0.127s][warning][jfr,redact] defaults, or specify -XX:FlightRecorderOptions:'redact-key=none;<filters>'
[0.127s][warning][jfr,redact] to replace without a warning.
LEAK-ENV-TOKEN-1           RAW=LEAK  EVENTS=1
LEAK-PROP-PASSWORD-2       RAW=LEAK  EVENTS=1
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=LEAK  EVENTS=2
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=clean EVENTS=0
LEAK-ARG-DBPASS-7          RAW=LEAK  EVENTS=2
exit=2
The JVM warns you, in the first three lines, and the damage is visible: LEAK-ENV-TOKEN-1 and LEAK-PROP-PASSWORD-2, both safe on defaults, are back. Use + unless you have a reason not to. Unreadable filter files. Long lists belong in a file, loaded with @. If the file cannot be read, JFR does not quietly carry on unredacted; the JVM refuses to start. Output from output/06-jdk27-missing-filter-file.txt:
[0.105s][warning][jfr,redact] Default redaction filters are replaced. Prepend with '+' to add filters to the
[0.105s][warning][jfr,redact] defaults, or specify -XX:FlightRecorderOptions:'redact-key=none;<filters>'
[0.105s][warning][jfr,redact] to replace without a warning.
[0.105s][error  ][jfr,redact] Failed to access redaction file /no/such/keys.txt
Error occurred during initialization of VM
Failure when starting JFR on_create_vm_2
exit=1
That is the right failure mode for a security control: loud and early. The path was a deliberate typo. Note that the “defaults are replaced” warning appears here too, because a custom redact-key without + was in play.
Fingerprint of the anchored-glob bug. You added a filter, jfr print shows [REDACTED] for some values, and the one you cared about is still readable. The cure is not more filters; it is the scanner. Search for the secret, not for the placeholder.

Filter files and the debug log: how to see what matched

For anything beyond two filters, put them in files. One filter per line, trailing whitespace ignored, and the same file can feed both options. The two files used here are filters/keys.txt and filters/args.txt.
-XX:FlightRecorderOptions:redact-key=+@filters/keys.txt,redact-argument=+@filters/args.txt -Xlog:jfr+redact=debug
The log option -Xlog:jfr+redact=debug prints one line per redaction. This is the fastest way to debug a filter, because it names the key that matched. Output from output/05-jdk27-filter-files-and-debug-log.txt:
[0.153s][debug][jfr,redact] Redaction file <module>/filters/args.txt read successfully
[0.156s][debug][jfr,redact] Redaction file <module>/filters/keys.txt read successfully
[0.157s][debug][jfr,redact] Redacted initial environment variable named 'ACCESS_TOKEN'
[0.157s][debug][jfr,redact] Redacted initial environment variable named 'DB_CONN'
[0.157s][debug][jfr,redact] Redacted initial system property named 'javax.net.ssl.keyStorePassword'
[0.157s][debug][jfr,redact] Redacted initial system property named 'db-pass'
[0.157s][debug][jfr,redact] Redacted initial system property named 'sun.java.command'
[0.157s][debug][jfr,redact] Redacted Java Arguments 'Workload [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED] --verbose'
[0.157s][debug][jfr,redact] Redacted JVM Arguments '-XX:StartFlightRecording:filename=/tmp/jfr-redaction/files.jfr -XX:FlightRecorderOptions:redact-key=+@<module>/filters/keys.txt,redact-argument=[REDACTED] -Xlog:jfr+redact=debug -Xmx256m [REDACTED] -Ddb-pass=[REDACTED] -Dapp.region=mumbai'
[0.157s][debug][jfr,redact] Redacted string flag 'FlightRecorderOptions'
LEAK-ENV-TOKEN-1           RAW=clean EVENTS=0
LEAK-PROP-PASSWORD-2       RAW=clean EVENTS=0
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=clean EVENTS=0
LEAK-ENV-CONN-5            RAW=clean EVENTS=0
LEAK-PROP-DBPASS-6         RAW=clean EVENTS=0
LEAK-ARG-DBPASS-7          RAW=clean EVENTS=0
exit=0
Every key is named by the log, and every secret is gone from the bytes. Note the sun.java.command line: the program’s command line is recorded twice (as javaArguments and as that system property) and both copies came out redacted.
Going deeper: matching an option and its value, and the filter syntax A filter in redact-argument can be several space-separated globs that match consecutive arguments. --password * redacts --password and whatever follows it; add another * to take a third argument. This matters because matching the value would force you to write the secret into the filter, which leaks it. The grammar from the JEP is: a filter list is none or one or more filters separated by ;, optionally prefixed with +; a filter is a glob or @filename. The files are read once at JVM startup, so changing one needs a restart. The JEP also notes that the same filter file may be used for both sub-options. Source: JEP 536.

Does it apply to recordings you start later with jcmd?

In the profiling workflow from the earlier post you often do not start the JVM with a recording flag at all; you attach with jcmd <pid> JFR.start to a process that is already running. I checked that this path is covered. The JVM was launched with no JFR flags and no redaction flags, and the recording was started afterwards. Output from output/08-jdk27-jcmd-late-recording.txt:
7047:
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 7047 JFR.dump name=late to copy recording data to file.
7047:
Stopped recording "late", 206.8 kB written to:

<tmp>/late.jfr
LEAK-ENV-TOKEN-1           RAW=clean EVENTS=0
LEAK-PROP-PASSWORD-2       RAW=clean EVENTS=0
LEAK-ARG-DBPASSWORD-3      RAW=clean EVENTS=0
LEAK-URL-PW-4              RAW=LEAK  EVENTS=2
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=LEAK  EVENTS=2
LEAK-ARG-DBPASS-7          RAW=LEAK  EVENTS=2
exit=2
Same result as the default run: the three obvious secrets are redacted, and the four custom ones leak. So redaction is a property of the JVM’s startup data, not of how the recording is started. It also means the fix for the late-attach case is to put the filter flags in the launch command, because the JEP says filter files are read at JVM startup. I did not try to change filters on a running JVM.

The opt-out, and when it is legitimate

Redaction can be switched off per option, or both. Output from output/03-jdk27-opt-out.txt, same program, same secrets:
-XX:FlightRecorderOptions:redact-argument=none,redact-key=none
LEAK-ENV-TOKEN-1           RAW=LEAK  EVENTS=1
LEAK-PROP-PASSWORD-2       RAW=LEAK  EVENTS=2
LEAK-ARG-DBPASSWORD-3      RAW=LEAK  EVENTS=2
LEAK-URL-PW-4              RAW=LEAK  EVENTS=2
LEAK-ENV-CONN-5            RAW=LEAK  EVENTS=1
LEAK-PROP-DBPASS-6         RAW=LEAK  EVENTS=2
LEAK-ARG-DBPASS-7          RAW=LEAK  EVENTS=2
exit=2
All seven are back, which is also a useful sanity check: the earlier clean results were caused by the redaction setting, not by the harness failing to plant the values. The JEP’s own advice is that sensitive data from the shell is rarely needed for troubleshooting, and when it is, the [REDACTED] marker tells you redaction is in effect and the documentation tells you how to relax it. The compatibility note is worth a line in your upgrade checklist: recordings from JDK 27 can contain [REDACTED] where JDK 25 recordings had real values, so any tooling that parses those fields will see the placeholder.

A workflow you can put in CI

The whole point is to make “is this safe to send?” a command with an exit code. The pattern from this post:
  1. Launch with the filters your organisation needs, in a shared filter file, using +.
  2. Take the recording as usual (-XX:StartFlightRecording or jcmd JFR.start, as in the profiling post).
  3. Run java -cp build Scan recording.jfr <known secret> ... against a test environment that uses canary secrets. It exits 2 if any appear and 0 if none do, which is exactly what the output files above show.
The canary approach is the important part. You cannot enumerate unknown secrets, but you can put a recognisable fake value in each place a real one would go in staging, and fail the build if the fake value shows up in a recording. src/Scan.java is 35 lines and has no dependencies.

Where redaction stops

Redaction in JEP 536 covers command-line arguments and the initial values of environment variables and system properties. That is its stated scope, and I only tested those. Everything else a recording can contain is outside this post’s evidence: values your own code puts into custom events, exception messages, thread names, anything the application logs through JFR. Likewise, a heap dump (see the heap dump and MAT post) is a different artefact entirely and contains live object contents. If a secret is a field in an object, redaction of startup data does nothing for it. I did not test those cases, so I will not claim more than the JEP does.
Should you rely on this alone? No, and the numbers say why: with defaults, 3 of 7 planted secrets were caught and 4 were not. Treat JEP 536 as a strong default plus a tuning exercise, not as a guarantee. Add filters for your own naming conventions with +, keep a canary check in CI, and still avoid putting secrets on command lines at all, because process listings and shell history see them long before JFR does. If you are on JDK 25 or earlier, there is no in-process redaction: the same 7 of 7 leak, and your choices are to disable the startup events or run jfr scrub on every file before it leaves the host.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.