diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md
index 37f6884..d5ba1c3 100644
--- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md
+++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md
@@ -45,6 +45,6 @@ This phase protects the analysis service from abuse with per-IP rate limiting an
- [x] `GET /api/health` returns `{status, version, uptime, scannerCount}`
- [x] All routers and CORS middleware are registered in `main.py`
- [x] Application starts statelessly — no database, no session management. Verified `backend/app/main.py` registers only CORS + rate limiter middleware and does not initialize any DB/session services.
-- [ ] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close)
+- [x] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close)
- [ ] Linting passes on both sides
- [ ] Run `/speckit.analyze` to verify consistency
diff --git a/frontend/src/__tests__/CaptchaChallenge.test.tsx b/frontend/src/__tests__/CaptchaChallenge.test.tsx
index f8b1f1c..5bcbfc2 100644
--- a/frontend/src/__tests__/CaptchaChallenge.test.tsx
+++ b/frontend/src/__tests__/CaptchaChallenge.test.tsx
@@ -194,4 +194,40 @@ describe("CaptchaChallenge", () => {
expect(onClose).toHaveBeenCalled();
});
+
+ it("keeps focus trapped within the modal when tabbing", () => {
+ const { container } = render(
+ ,
+ );
+
+ const closeButton = getByTestId(container, "captcha-close") as HTMLButtonElement;
+ const submit = getByTestId(container, "captcha-submit") as HTMLButtonElement;
+
+ submit.focus();
+ expect(document.activeElement).toBe(submit);
+
+ act(() => {
+ submit.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
+ });
+
+ expect(document.activeElement).toBe(closeButton);
+
+ closeButton.focus();
+ expect(document.activeElement).toBe(closeButton);
+
+ act(() => {
+ closeButton.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }),
+ );
+ });
+
+ expect(document.activeElement).toBe(submit);
+ });
});
diff --git a/frontend/src/components/CaptchaChallenge.tsx b/frontend/src/components/CaptchaChallenge.tsx
index 11348f6..e478edd 100644
--- a/frontend/src/components/CaptchaChallenge.tsx
+++ b/frontend/src/components/CaptchaChallenge.tsx
@@ -44,6 +44,7 @@ export default function CaptchaChallenge({
const titleId = useId();
const inputRef = useRef(null);
const answerRef = useRef("");
+ const modalRef = useRef(null);
useLayoutEffect(() => {
if (!isOpen) {
@@ -55,6 +56,13 @@ export default function CaptchaChallenge({
answerRef.current = "";
}, [isOpen, challenge?.challengeToken]);
+ useLayoutEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+ inputRef.current?.focus();
+ }, [isOpen, challenge?.challengeToken]);
+
const commitAnswer = useCallback((value: string) => {
answerRef.current = value;
setAnswer(value);
@@ -88,6 +96,43 @@ export default function CaptchaChallenge({
if (event.key === "Escape") {
event.preventDefault();
onClose();
+ return;
+ }
+
+ if (event.key !== "Tab") {
+ return;
+ }
+ const container = modalRef.current;
+ if (!container) {
+ return;
+ }
+ const focusableElements = Array.from(
+ container.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ ),
+ ).filter(
+ (element) =>
+ !element.hasAttribute("disabled") &&
+ element.getAttribute("aria-hidden") !== "true",
+ );
+
+ if (focusableElements.length === 0) {
+ return;
+ }
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+ const activeElement = document.activeElement;
+
+ if (event.shiftKey && activeElement === firstElement) {
+ event.preventDefault();
+ lastElement.focus();
+ return;
+ }
+
+ if (!event.shiftKey && activeElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
}
},
[onClose],
@@ -117,6 +162,7 @@ export default function CaptchaChallenge({
data-testid="captcha-challenge"
onKeyDown={handleKeyDown}
tabIndex={-1}
+ ref={modalRef}
>