Recording Web Traffic, HTTP(S) Test Script Recorder, and Certificate Setup: Guided Hands-On Workflow
The workflow deliberately separates four states: local fixture TLS, browser recorder-proxy settings, browser trust in JMeter's temporary CA, and the final JMeter replay plan. Keeping these states separate is what makes certificate cleanup and correlation diagnosis safe.
Learning objectives
- Start a reproducible HTTP+HTTPS fixture using only the Java 17 JDK.
- Create a disposable Firefox profile and route it only through the local recorder.
- Capture HTTP first, then HTTPS with the temporary recorder CA trusted only in that profile.
- Filter static resources and preserve the raw recording.
- Refactor the capture into two Transaction Controllers with defaults, Cookie Manager, and correlation.
- Remove proxy/trust state before executing a bounded CLI replay.
1. Hard safety envelope
http://localhost:8080 and
https://localhost:8443. Recorder listens only on
127.0.0.1:8888. Use one disposable browser profile.
Clean replay uses 1 thread × 3 loops maximum. Close unrelated
browser windows/tabs/apps before enabling the proxy. Abort
immediately if any non-local host appears in the recorded tree.
2. Create the fixture's short-lived localhost certificate
This certificate belongs to the target fixture, not the
recorder. The Java JDK's keytool creates a PKCS#12
keystore:
PowerShell:
New-Item -ItemType Directory -Force fixtures, results | Out-Null
keytool -genkeypair `
-alias localfixture `
-keyalg RSA `
-keysize 2048 `
-validity 2 `
-storetype PKCS12 `
-keystore fixtures\fixture.p12 `
-storepass changeit `
-keypass changeit `
-dname "CN=localhost, OU=JMeter Lab, O=DevOps Academy, C=US" `
-ext "SAN=dns:localhost,ip:127.0.0.1"
Bash:
mkdir -p fixtures results
keytool -genkeypair -alias localfixture -keyalg RSA -keysize 2048 -validity 2 -storetype PKCS12 -keystore fixtures/fixture.p12 -storepass changeit -keypass changeit -dname "CN=localhost, OU=JMeter Lab, O=DevOps Academy, C=US" -ext "SAN=dns:localhost,ip:127.0.0.1"
The password is a disposable lab value. Do not copy this pattern to production certificates.
3. Compile and start the dual HTTP/HTTPS fixture
Save as fixtures/RecorderFixture.java:
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsServer;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public final class RecorderFixture {
private static final AtomicInteger SEQ = new AtomicInteger();
private static final Map<String, Flow> FLOWS = new ConcurrentHashMap<>();
private static Path eventLog;
private record Flow(String flowId, String csrf) {}
public static void main(String[] args) throws Exception {
if (args.length != 3) {
System.err.println("usage: RecorderFixture <fixture.p12> <password> <event-log.jsonl>");
System.exit(2);
}
Path keyStorePath = Path.of(args[0]).toAbsolutePath();
char[] password = args[1].toCharArray();
eventLog = Path.of(args[2]).toAbsolutePath();
Files.createDirectories(eventLog.getParent());
Files.writeString(eventLog, "", StandardCharsets.UTF_8);
SSLContext sslContext = sslContext(keyStorePath, password);
HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0);
HttpsServer https = HttpsServer.create(new InetSocketAddress("127.0.0.1", 8443), 0);
https.setHttpsConfigurator(new HttpsConfigurator(sslContext));
configure(http);
configure(https);
http.setExecutor(Executors.newFixedThreadPool(4));
https.setExecutor(Executors.newFixedThreadPool(4));
http.start();
https.start();
System.out.println("http_fixture=http://localhost:8080/begin");
System.out.println("https_fixture=https://localhost:8443/begin");
System.out.println("event_log=" + eventLog);
}
private static SSLContext sslContext(Path p12, char[] password) throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (var in = Files.newInputStream(p12)) {
ks.load(in, password);
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, password);
SSLContext context = SSLContext.getInstance("TLS");
context.init(kmf.getKeyManagers(), null, null);
return context;
}
private static void configure(HttpServer server) {
server.createContext("/health", exchange -> send(exchange, 200, "application/json",
"{\"status\":\"ok\"}".getBytes(StandardCharsets.UTF_8)));
server.createContext("/begin", new BeginHandler());
server.createContext("/finish", new FinishHandler());
server.createContext("/static/site.css", exchange -> send(exchange, 200, "text/css",
"body{font-family:sans-serif}.card{max-width:42rem;margin:3rem auto}".getBytes(StandardCharsets.UTF_8)));
server.createContext("/static/app.js", exchange -> send(exchange, 200, "application/javascript",
"console.log('synthetic recorder fixture');".getBytes(StandardCharsets.UTF_8)));
server.createContext("/static/pixel.gif", exchange -> send(exchange, 200, "image/gif",
new byte[]{'G','I','F','8','9','a'}));
server.createContext("/stats", exchange -> {
String body = "{\"active_flows\":" + FLOWS.size() + ",\"sequence\":" + SEQ.get() + "}";
send(exchange, 200, "application/json", body.getBytes(StandardCharsets.UTF_8));
});
}
private static final class BeginHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
int n = SEQ.incrementAndGet();
String flowId = "FLOW-" + String.format("%05d", n);
String csrf = "CSRF-" + String.format("%05d", n);
String session = "LABSESSION-" + String.format("%05d", n);
FLOWS.put(session, new Flow(flowId, csrf));
Headers h = exchange.getResponseHeaders();
h.add("Set-Cookie", "LABSESSION=" + session + "; Path=/; Secure; HttpOnly; SameSite=Lax");
String html = """
<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Recorder Fixture</title>
<link rel="stylesheet" href="/static/site.css">
<script src="/static/app.js"></script>
</head><body>
<main class="card">
<h1>Synthetic checkout</h1>
<img src="/static/pixel.gif" alt="" width="1" height="1">
<form method="post" action="/finish">
<input type="hidden" name="flow_id" value="%s">
<input type="hidden" name="csrf" value="%s">
<label>Email <input name="email" value="student@example.invalid"></label>
<button type="submit">Complete</button>
</form>
</main>
</body></html>
""".formatted(flowId, csrf);
log(exchange, 200, flowId, session, "begin");
send(exchange, 200, "text/html; charset=utf-8", html.getBytes(StandardCharsets.UTF_8));
}
}
private static final class FinishHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
log(exchange, 405, "", "", "method_not_allowed");
send(exchange, 405, "application/json",
"{\"status\":\"method_not_allowed\"}".getBytes(StandardCharsets.UTF_8));
return;
}
String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
Map<String, String> form = parseForm(requestBody);
String flowId = form.getOrDefault("flow_id", "");
String csrf = form.getOrDefault("csrf", "");
String session = cookie(exchange.getRequestHeaders().getFirst("Cookie"), "LABSESSION");
Flow expected = FLOWS.get(session);
boolean ok = expected != null
&& expected.flowId().equals(flowId)
&& expected.csrf().equals(csrf)
&& "student@example.invalid".equals(form.get("email"));
if (ok) {
FLOWS.remove(session);
log(exchange, 200, flowId, session, "accepted");
String html = "<!doctype html><html><body><h1>Accepted</h1><p id=\"status\">accepted</p></body></html>";
send(exchange, 200, "text/html; charset=utf-8", html.getBytes(StandardCharsets.UTF_8));
} else {
log(exchange, 409, flowId, session, "correlation_failure");
String body = "{\"status\":\"rejected\",\"reason\":\"correlation_or_cookie_mismatch\"}";
send(exchange, 409, "application/json", body.getBytes(StandardCharsets.UTF_8));
}
}
}
private static Map<String, String> parseForm(String body) {
Map<String, String> out = new HashMap<>();
if (body == null || body.isBlank()) return out;
for (String pair : body.split("&")) {
String[] parts = pair.split("=", 2);
String key = URLDecoder.decode(parts[0], StandardCharsets.UTF_8);
String value = parts.length == 2
? URLDecoder.decode(parts[1], StandardCharsets.UTF_8)
: "";
out.put(key, value);
}
return out;
}
private static String cookie(String header, String name) {
if (header == null) return "";
for (String part : header.split(";")) {
String trimmed = part.trim();
int idx = trimmed.indexOf('=');
if (idx > 0 && name.equals(trimmed.substring(0, idx))) {
return trimmed.substring(idx + 1);
}
}
return "";
}
private static void send(HttpExchange exchange, int status, String contentType, byte[] body) throws IOException {
exchange.getResponseHeaders().set("Content-Type", contentType);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
private static synchronized void log(HttpExchange exchange, int status, String flow, String session, String note)
throws IOException {
String json = "{"
+ "\"ts\":\"" + Instant.now() + "\","
+ "\"method\":\"" + escapeJson(exchange.getRequestMethod()) + "\","
+ "\"path\":\"" + escapeJson(exchange.getRequestURI().getPath()) + "\","
+ "\"status\":" + status + ","
+ "\"flow\":\"" + escapeJson(flow) + "\","
+ "\"session\":\"" + escapeJson(session) + "\","
+ "\"note\":\"" + escapeJson(note) + "\""
+ "}\n";
Files.writeString(eventLog, json, StandardCharsets.UTF_8,
java.nio.file.StandardOpenOption.CREATE,
java.nio.file.StandardOpenOption.APPEND);
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
Compile and run:
javac -d fixtures fixtures\RecorderFixture.java
java -cp fixtures RecorderFixture fixtures\fixture.p12 changeit results\server-events.jsonl
The fixture exposes /begin on ports 8080 and 8443. The
page loads synthetic CSS/JS/image noise and submits a dynamic
flow_id + csrf form to
/finish. A secure LABSESSION cookie binds
the flow.
4. Preflight target and tool state
java -version
javac -version
keytool -help | Select-Object -First 5
(Invoke-WebRequest -UseBasicParsing http://localhost:8080/health).Content
# For HTTPS preflight, curl may require -k because the fixture certificate is intentionally self-signed.
curl.exe -k https://localhost:8443/health
-k is used only for the local disposable target
preflight. It is not a recommendation to disable verification for
real environments.
5. Create a disposable Firefox profile
Use a dedicated profile directory that contains no normal browsing state. PowerShell example:
$Profile = Join-Path $env:TEMP "jmeter-recorder-profile"
Remove-Item -Recurse -Force $Profile -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $Profile | Out-Null
# Adjust path if Firefox is installed elsewhere.
$Firefox = "$env:ProgramFiles\Mozilla Firefox\firefox.exe"
& $Firefox -no-remote -profile $Profile
Linux example:
PROFILE="$(mktemp -d)"
firefox -no-remote -profile "$PROFILE"
6. Create the recorder authoring tree
In JMeter GUI, the Recording template is acceptable, or build:
Test Plan
├── HTTP Request Defaults
│ Server: localhost
├── HTTP Cookie Manager # for later replay; browser handles cookies during recording
├── Thread Group — "Clean Replay"
│ └── Recording Controller — "Raw Capture Target"
└── HTTP(S) Test Script Recorder
Port: 8888
Target Controller: Raw Capture Target
Do not run the Clean Replay Thread Group while recording. Recorder is an authoring proxy; meaningful load execution comes after refactoring.
7. Configure Firefox proxy narrowly
In the disposable Firefox profile → Network Settings:
- Manual proxy configuration.
- HTTP Proxy:
127.0.0.1, Port8888. -
HTTPS Proxy:
127.0.0.1, Port8888(or use the “also use for HTTPS” option). - Clear localhost/127.0.0.1 from “No proxy for” so the local fixture actually crosses JMeter.
If the browser still works against the fixture while the recorder is stopped, the browser is bypassing the recorder—fix the proxy path before continuing.
8. First capture: local HTTP without CA trust
Recorder filters for the first tiny capture:
Include: localhost:8080/.*
Exclude: (leave empty for this first observation)
Start Recorder, navigate to
http://localhost:8080/begin, and submit the form once.
Stop Recorder immediately.
Expected raw capture includes business requests plus
/static/site.css, /static/app.js, and
/static/pixel.gif. This draft proves why filtering is
necessary. Save it as raw-http-unfiltered.jmx; do not
load-test it.
9. Add capture filters before HTTPS recording
Clear or use a fresh Recording Controller. Configure:
Include:
localhost:8443/.*
Exclude:
.*\.(?i:css|js|png|gif|svg|ico)(\?.*)?
Remember: patterns are checked against host+port+path+query, not a
full URL containing https://.
10. Start Recorder and inspect the generated CA before trusting it
Start the recorder. JMeter generates recorder certificate material
when needed and shows its root CA details. With JMeter launched from
its bin directory, the exported file is normally there:
keytool -printcert -file .\ApacheJMeterTemporaryRootCA.crt
Compare subject, validity, and fingerprint/details with the JMeter dialog. Do not import a different file just because the filename looks familiar.
11. Import recorder CA only into the disposable Firefox profile
Firefox profile → Settings → Privacy & Security → Certificates →
View Certificates → Authorities → Import. Choose the exact
ApacheJMeterTemporaryRootCA.crt just inspected, verify
details, and allow it to identify websites
only inside this disposable profile.
12. Record the short HTTPS journey
With Recorder running and filters active:
- Navigate to
https://localhost:8443/begin. - Confirm the synthetic page appears through the proxy.
- Click Complete once.
- Stop Recorder immediately.
-
Save this untouched draft as
raw-https-filtered.jmx.
Expected stored business requests: GET /begin and
POST /finish. Static assets crossed the proxy but were
excluded from capture.
13. Remove proxy and CA trust before refactoring
- Stop the JMeter Recorder.
- Set Firefox Network Settings back to No proxy/System proxy.
- Remove the JMeter recorder CA from the disposable profile Authorities list, or close and delete the entire disposable profile directory.
- Verify the normal browser profile was never changed.
Do this before running the cleaned load plan. Recorder trust is authoring state, not load-runtime state.
14. Inventory the raw JMX
Search for:
-
literal
FLOW-#####andCSRF-#####values in POST parameters; -
any literal
LABSESSION-#####, Cookie, Authorization, bearer token, password, or non-local host; - duplicate host/protocol/port repeated on every sampler;
- static resources or redirects that survived filtering;
- recorded browser headers that are not needed for the performance question.
15. Refactor into a small reusable plan
Test Plan
├── User Defined Variables
│ LAB_HOST = localhost
│ LAB_PORT = 8443
├── HTTP Request Defaults
│ Protocol=https; Server=${LAB_HOST}; Port=${LAB_PORT}
├── HTTP Cookie Manager
└── Thread Group — 1 user × 3 loops maximum
├── Transaction Controller — "Start Flow"
│ └── Begin — GET /begin
│ ├── CSS Selector Extractor
│ │ FLOW_ID = input[name=flow_id] -> value
│ └── CSS Selector Extractor
│ CSRF_TOKEN = input[name=csrf] -> value
└── Transaction Controller — "Complete Flow"
└── Finish — POST /finish
flow_id=${FLOW_ID}
csrf=${CSRF_TOKEN}
email=student@example.invalid
Add correctness assertions: Begin must expose both hidden fields; Finish must return HTTP 200 and contain the synthetic accepted marker. The cookie comes from HTTP Cookie Manager, not a hard-coded Cookie header.
16. Scan raw and cleaned plans
Save as tools/scan_jmx.py:
import re
import sys
from pathlib import Path
if len(sys.argv) < 2:
raise SystemExit("usage: scan_jmx.py <plan.jmx> [--clean]")
path = Path(sys.argv[1])
clean_mode = "--clean" in sys.argv[2:]
text = path.read_text(encoding="utf-8", errors="replace")
checks = {
"literal_flow": re.compile(r"FLOW-\d{5}"),
"literal_csrf": re.compile(r"CSRF-\d{5}"),
"literal_session": re.compile(r"LABSESSION-\d{5}"),
"authorization_header": re.compile(r"(?i)\bAuthorization\b"),
"bearer_value": re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/\-=]{8,}"),
"nonlocal_http_target": re.compile(
r"https?://(?!(?:localhost|127\.0\.0\.1)(?::\d+)?(?:/|$))[A-Za-z0-9.-]+",
re.I,
),
}
hits = []
for name, pattern in checks.items():
found = sorted(set(pattern.findall(text)))
if found:
hits.append((name, found[:10]))
print(f"file={path}")
print(f"mode={'clean' if clean_mode else 'inventory'}")
if hits:
for name, found in hits:
print(f"HIT {name}: {found}")
else:
print("No configured suspicious patterns found.")
if clean_mode and hits:
raise SystemExit(
"Clean-plan scan failed. Investigate each hit; do not merely weaken the scanner."
)
Inventory raw capture:
python tools/scan_jmx.py raw-https-filtered.jmx
Require clean plan to pass:
python tools/scan_jmx.py cleaned-two-transactions.jmx --clean
A scanner is a guardrail, not proof that a plan contains no secret. Review Header Managers, parameters, filenames, properties, and external data sources manually too.
17. Preserve a cleaned-JMX diff
git diff --no-index -- raw-https-filtered.jmx cleaned-two-transactions.jmx > cleaned-jmx.diff
Expected conceptual diff: remove static/noise/browser-specific headers; centralize protocol/host/port; add Cookie Manager; replace literal dynamic values with variables; add extractors/assertions; group requests into two business transactions.
18. Execute only the cleaned plan in CLI mode
jmeter.bat -n `
-t cleaned-two-transactions.jmx `
-l results\cleaned.jtl `
-j results\cleaned-jmeter.log
Maximum 1 thread × 3 loops. Preserve JTL, matching
jmeter.log, and target events. Do not replay the raw
recording as a load test.
19. Analyze target evidence
Save as tools/analyze_recorder_events.py:
import json
import sys
from collections import Counter
from pathlib import Path
path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/server-events.jsonl")
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
print(f"events={len(events)}")
print(f"methods={dict(Counter(e['method'] for e in events))}")
print(f"paths={dict(Counter(e['path'] for e in events))}")
print(f"statuses={dict(Counter(e['status'] for e in events))}")
print(f"notes={dict(Counter(e['note'] for e in events))}")
accepted = [e for e in events if e["note"] == "accepted"]
failed = [e for e in events if e["note"] == "correlation_failure"]
print(f"accepted_finishes={len(accepted)}")
print(f"correlation_failures={len(failed)}")
For a 3-loop cleaned run, expected business target events are three
/begin and three accepted /finish events;
no static assets are explicitly replayed by the cleaned plan.
20. Challenge
The recorder captured ten requests: two business API calls, four static assets, two analytics beacons, one third-party font, and one redirect. What should determine the final plan?
Start from the performance question and business causality. Keep/construct only requests needed to reproduce the intended server-side journey, correlation, and protocol state. Do not preserve requests merely because the browser happened to make them.
Knowledge check
Why record HTTP once before HTTPS?
It proves the browser→recorder proxy path and filtering without adding certificate trust as a second variable.
Which certificate is imported into Firefox?
The JMeter recorder root CA, not the fixture's self-signed server certificate.
Why is the raw POST not replay-safe?
It contains dynamic flow/csrf values captured from one session rather than variables extracted for each replay iteration.
Why must the recorder stop and trust be removed before CLI replay?
Proxy/CA trust are authoring-only interception state; the cleaned JMX should replay directly and independently.
What proves cleaned correlation works?
Each fresh /begin response produces new values/cookie and the corresponding /finish is independently accepted by the target with no literal captured token.
Official references and version notes
- Component Reference — HTTP(S) Test Script Recorder — proxy, filtering, recording controller, certificate generation, CA trust, cookies, redirects, and UDV replacement.
- HTTP(S) Test Script Recorder tutorial — current recording-template workflow and browser proxy setup.
- Getting Started — Java/JDK requirement, keytool note for HTTPS recording, GUI/CLI conventions, and JMeter HTTP certificate behavior.
- Properties Reference — recorder and certificate properties such as proxy.cert.validity, proxy.ssl.protocol, proxy.pause, and redirect handling.
- Best Practices — using the recorder for rough drafts, variable replacement, and checking proxy routing.
- Apache JMeter downloads — current stable release and Java requirement.
Version-sensitive statements were rechecked against current Apache
JMeter primary documentation on 2026-09-05. The course baseline
remains Apache JMeter 5.6.3 with a Java 17 JDK
and no third-party JMeter plugins; JMeter 5.6.3 requires Java 8+.
A JDK is preferred here because HTTPS recording needs the
keytool utility. HTTP(S) Test Script Recorder is a
local HTTP/HTTPS proxy, default port 8888.
Include/Exclude patterns are regular expressions evaluated against
the full host/port/path/query string; requests still pass through
the proxy even when a filter prevents them from being stored. With
Java 8+ dynamic certificate mode, JMeter generates per-host
certificates signed by its temporary root CA; the default recorder
certificate validity is 7 days via
proxy.cert.validity. The exported
ApacheJMeterTemporaryRootCA.crt is created when
recorder certificates are generated and must be removed from
browser trust after use. Anyone who can access the recorder
keystore/private-key material while a browser trusts that CA has a
powerful interception capability, so the mandatory lab imports it
only into a disposable Firefox profile. During recording, the
browser manages cookies; the cleaned JMeter replay plan needs an
HTTP Cookie Manager. JMeter's HTTP samplers accept server
certificates broadly for test flexibility, so the synthetic
localhost target can use its own short-lived self-signed fixture
certificate without importing that target certificate into the
browser or OS trust store.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.