post(String path, Object body) {
+ return given()
+ .when()
+ .relaxedHTTPSValidation()
+ .contentType(ContentType.JSON)
+ .cookie("JSESSIONID", getWebGoatCookie())
+ .body(json(body))
+ .post(webGoatUrlConfig.url(path))
+ .then()
+ .statusCode(200)
+ .extract()
+ .jsonPath()
+ .getMap("$");
+ }
+
+ private static String json(Object value) {
+ return WebAuthnSupport.toJson(value);
+ }
+}
diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java
new file mode 100644
index 00000000000..5ec5087c43e
--- /dev/null
+++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.playwright.webgoat.lessons;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+import com.microsoft.playwright.Browser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.owasp.webgoat.container.lessons.LessonName;
+import org.owasp.webgoat.playwright.webgoat.PlaywrightTest;
+import org.owasp.webgoat.playwright.webgoat.helpers.Authentication;
+import org.owasp.webgoat.playwright.webgoat.pages.lessons.WebAuthnLessonPage;
+
+public class WebAuthnLessonUITest extends PlaywrightTest {
+
+ private WebAuthnLessonPage lessonPage;
+
+ @BeforeEach
+ void navigateToLesson(Browser browser) {
+ var lessonName = new LessonName("WebAuthn");
+ var page = Authentication.sylvester(browser);
+
+ this.lessonPage = new WebAuthnLessonPage(page);
+ lessonPage.resetLesson(lessonName);
+ lessonPage.open(lessonName);
+ }
+
+ @Test
+ @DisplayName("Solve the GoatBank registration and authentication assignments and the quiz")
+ void shouldSolveGoatBankAssignments() {
+ lessonPage.navigateTo(4);
+ lessonPage.registerKeyForJerry();
+ assertThat(lessonPage.registrationFeedback()).containsText("Jerry");
+ assertThat(lessonPage.registrationFeedback()).containsText("Well done");
+
+ lessonPage.navigateTo(6);
+ lessonPage.replayCapturedLogin();
+ assertThat(lessonPage.authenticationFeedback()).containsText("signed in as Jerry");
+
+ lessonPage.navigateTo(8);
+ lessonPage.solveQuiz();
+ }
+
+ @Test
+ @DisplayName("Register and sign in with a (virtual) roaming security key")
+ void shouldSignInWithSecurityKey() {
+ lessonPage.attachVirtualSecurityKey();
+ lessonPage.navigateTo(10);
+
+ lessonPage.registerSecurityKey();
+ assertThat(lessonPage.securityKeyRegistration()).containsText("\"registered\": true");
+ assertThat(lessonPage.securityKeyRegistration()).containsText("BE=false");
+
+ lessonPage.signInWithSecurityKey();
+ assertThat(lessonPage.securityKeyFeedback()).containsText("You signed in with your security key");
+ }
+}
diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java
new file mode 100644
index 00000000000..463a55dd04b
--- /dev/null
+++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java
@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.playwright.webgoat.pages.lessons;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+import com.google.gson.JsonObject;
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.Page;
+import java.util.regex.Pattern;
+
+public class WebAuthnLessonPage extends LessonPage {
+
+ public WebAuthnLessonPage(Page page) {
+ super(page);
+ }
+
+ /** Registers a new key while replacing Tom's user handle ("dG9t") with Jerry's. */
+ public void registerKeyForJerry() {
+ Page page = getPage();
+ page.locator("#webauthn-reg-begin").click();
+ assertThat(page.locator("#webauthn-reg-touch")).isEnabled();
+ page.locator("#webauthn-reg-touch").click();
+ Locator body = page.locator("#webauthn-reg-body");
+ assertThat(body).hasValue(Pattern.compile("attestationObject"));
+ body.fill(body.inputValue().replace("\"dG9t\"", "\"amVycnk\""));
+ page.locator("form[action$='/WebAuthn/registration/finish'] button[type='submit']").click();
+ }
+
+ public Locator registrationFeedback() {
+ return feedbackOf("#webauthn-reg-body");
+ }
+
+ /** Copies the request from the support ticket into the sign in form and sends it. */
+ public void replayCapturedLogin() {
+ Page page = getPage();
+ page.locator("#webauthn-ticket-open").click();
+ Locator ticket = page.locator("#webauthn-ticket-content");
+ assertThat(ticket).containsText("authenticatorData");
+ String captured = ticket.textContent();
+ page.locator("#webauthn-login-body").fill(captured.substring(captured.indexOf('{')));
+ page.locator("form[action$='/WebAuthn/authentication/finish'] button[type='submit']").click();
+ }
+
+ public Locator authenticationFeedback() {
+ return feedbackOf("#webauthn-login-body");
+ }
+
+ public void solveQuiz() {
+ Page page = getPage();
+ page.waitForSelector("#q_container input[name='question_0_solution']");
+ page.locator("#question_0_2_input").check();
+ page.locator("#question_1_2_input").check();
+ page.locator("#question_2_2_input").check();
+ page.locator("#question_3_2_input").check();
+ page.locator("#question_4_1_input").check();
+ page.locator("#quiz-form input[type='SUBMIT']").click();
+ page.waitForSelector("#q_container .quiz_question.correct:nth-of-type(5)");
+ }
+
+ /**
+ * Attaches a virtual roaming authenticator (CTAP2 over USB) to the browser through the Chrome
+ * DevTools Protocol, the same as the WebAuthn panel in DevTools.
+ */
+ public void attachVirtualSecurityKey() {
+ var cdp = getPage().context().newCDPSession(getPage());
+ cdp.send("WebAuthn.enable");
+ JsonObject options = new JsonObject();
+ options.addProperty("protocol", "ctap2");
+ options.addProperty("transport", "usb");
+ options.addProperty("hasResidentKey", false);
+ options.addProperty("hasUserVerification", false);
+ options.addProperty("automaticPresenceSimulation", true);
+ JsonObject arguments = new JsonObject();
+ arguments.add("options", options);
+ cdp.send("WebAuthn.addVirtualAuthenticator", arguments);
+ }
+
+ public void registerSecurityKey() {
+ getPage().locator("#webauthn-sk-register").click();
+ }
+
+ public Locator securityKeyRegistration() {
+ return getPage().locator("#webauthn-sk-registration");
+ }
+
+ public void signInWithSecurityKey() {
+ getPage().locator("#webauthn-sk-login").click();
+ }
+
+ public Locator securityKeyFeedback() {
+ return feedbackOf("#webauthn-sk-form");
+ }
+
+ private Locator feedbackOf(String selector) {
+ Page page = getPage();
+ return page.locator(
+ ".attack-container", new Page.LocatorOptions().setHas(page.locator(selector)))
+ .locator(".attack-feedback");
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java b/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java
new file mode 100644
index 00000000000..3f19a68e4e7
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2016 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.container;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class ProxyAutoConfigController {
+
+ private final int proxyPort;
+
+ public ProxyAutoConfigController(@Value("${ZAP_PROXY_PORT:8090}") int proxyPort) {
+ this.proxyPort = proxyPort;
+ }
+
+ @GetMapping(value = "/proxy.pac", produces = "application/x-ns-proxy-autoconfig")
+ public String proxyAutoConfig() {
+ return "function FindProxyForURL(url, host) {\n"
+ + " return \"PROXY 127.0.0.1:" + proxyPort + "\";\n"
+ + "}\n";
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java
index 81e4d23cf33..6877c85f6c4 100644
--- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java
+++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java
@@ -39,6 +39,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/js/**",
"/fonts/**",
"/plugins/**",
+ "/proxy.pac",
"/registration",
"/register.mvc",
"/actuator/**")
diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java
new file mode 100644
index 00000000000..742143cadd3
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java
@@ -0,0 +1,214 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.webauthn;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal CBOR (RFC 8949) codec covering the subset used by WebAuthn: unsigned/negative integers,
+ * byte strings, text strings, arrays, maps and the simple values false/true/null. Integers are
+ * decoded as {@link Long}, byte strings as {@code byte[]}.
+ *
+ * This exists so the lesson can show every verification step. A production relying party
+ * should use a vetted WebAuthn library instead of hand-written parsing.
+ */
+public final class Cbor {
+
+ private static final int MAX_DEPTH = 16;
+
+ private Cbor() {}
+
+ public static Object decode(byte[] data) {
+ Decoder decoder = new Decoder(data, 0);
+ Object value = decoder.read(0);
+ if (decoder.position != data.length) {
+ throw new IllegalArgumentException("Trailing bytes after CBOR item");
+ }
+ return value;
+ }
+
+ /** Decodes the first CBOR item starting at {@code offset}; returns the item and its length. */
+ public static Item decodeFirst(byte[] data, int offset) {
+ Decoder decoder = new Decoder(data, offset);
+ Object value = decoder.read(0);
+ return new Item(value, decoder.position - offset);
+ }
+
+ public record Item(Object value, int length) {}
+
+ public static byte[] encode(Object value) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ write(out, value);
+ return out.toByteArray();
+ }
+
+ private static void write(ByteArrayOutputStream out, Object value) {
+ switch (value) {
+ case null -> out.write(0xf6);
+ case Boolean b -> out.write(b ? 0xf5 : 0xf4);
+ case Integer i -> write(out, i.longValue());
+ case Long l -> {
+ if (l >= 0) {
+ writeHead(out, 0, l);
+ } else {
+ writeHead(out, 1, -1 - l);
+ }
+ }
+ case byte[] bytes -> {
+ writeHead(out, 2, bytes.length);
+ out.writeBytes(bytes);
+ }
+ case String s -> {
+ byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+ writeHead(out, 3, bytes.length);
+ out.writeBytes(bytes);
+ }
+ case List> list -> {
+ writeHead(out, 4, list.size());
+ list.forEach(item -> write(out, item));
+ }
+ case Map, ?> map -> {
+ writeHead(out, 5, map.size());
+ map.forEach(
+ (k, v) -> {
+ write(out, k);
+ write(out, v);
+ });
+ }
+ default ->
+ throw new IllegalArgumentException("Unsupported CBOR type: " + value.getClass());
+ }
+ }
+
+ private static void writeHead(ByteArrayOutputStream out, int majorType, long length) {
+ int major = majorType << 5;
+ if (length < 24) {
+ out.write(major | (int) length);
+ } else if (length < 0x100) {
+ out.write(major | 24);
+ out.write((int) length);
+ } else if (length < 0x10000) {
+ out.write(major | 25);
+ writeBigEndian(out, length, 2);
+ } else if (length < 0x100000000L) {
+ out.write(major | 26);
+ writeBigEndian(out, length, 4);
+ } else {
+ out.write(major | 27);
+ writeBigEndian(out, length, 8);
+ }
+ }
+
+ private static void writeBigEndian(ByteArrayOutputStream out, long value, int bytes) {
+ for (int i = bytes - 1; i >= 0; i--) {
+ out.write((int) (value >>> (8 * i)) & 0xff);
+ }
+ }
+
+ private static final class Decoder {
+ private final byte[] data;
+ private int position;
+
+ Decoder(byte[] data, int position) {
+ this.data = data;
+ this.position = position;
+ }
+
+ Object read(int depth) {
+ if (depth > MAX_DEPTH) {
+ throw new IllegalArgumentException("CBOR nesting too deep");
+ }
+ int initial = nextByte();
+ int majorType = initial >>> 5;
+ int additional = initial & 0x1f;
+ if (majorType == 7) {
+ return switch (additional) {
+ case 20 -> Boolean.FALSE;
+ case 21 -> Boolean.TRUE;
+ case 22 -> null;
+ default -> throw new IllegalArgumentException("Unsupported CBOR simple value");
+ };
+ }
+ long argument = readArgument(additional);
+ return switch (majorType) {
+ case 0 -> argument;
+ case 1 -> -1 - argument;
+ case 2 -> readBytes(argument);
+ case 3 -> new String(readBytes(argument), StandardCharsets.UTF_8);
+ case 4 -> {
+ long size = checkedLength(argument);
+ List