diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py
index cb33ff4d2c01..959d30df00af 100644
--- a/authentik/stages/captcha/api.py
+++ b/authentik/stages/captcha/api.py
@@ -17,6 +17,7 @@ class Meta:
"private_key",
"js_url",
"api_url",
+ "request_content_type",
"interactive",
"score_min_threshold",
"score_max_threshold",
diff --git a/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py b/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py
new file mode 100644
index 000000000000..6c7d24dbe7df
--- /dev/null
+++ b/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.2.14 on 2026-05-14 23:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_captcha", "0004_captchastage_interactive"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="captchastage",
+ name="request_content_type",
+ field=models.TextField(
+ choices=[
+ ("application/x-www-form-urlencoded", "Form encoded"),
+ ("application/json", "JSON"),
+ ],
+ default="application/x-www-form-urlencoded",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py
index fb5a6dac2893..776911b91ead 100644
--- a/authentik/stages/captcha/models.py
+++ b/authentik/stages/captcha/models.py
@@ -8,6 +8,13 @@
from authentik.flows.models import Stage
+class CaptchaRequestContentType(models.TextChoices):
+ """Supported request content types for CAPTCHA verification."""
+
+ FORM = "application/x-www-form-urlencoded", _("Form encoded")
+ JSON = "application/json", _("JSON")
+
+
class CaptchaStage(Stage):
"""Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""
@@ -30,6 +37,10 @@ class CaptchaStage(Stage):
js_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api.js")
api_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api/siteverify")
+ request_content_type = models.TextField(
+ choices=CaptchaRequestContentType.choices,
+ default=CaptchaRequestContentType.FORM,
+ )
@property
def serializer(self) -> type[BaseSerializer]:
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
index 9e582e561449..66b823e7a771 100644
--- a/authentik/stages/captcha/stage.py
+++ b/authentik/stages/captcha/stage.py
@@ -15,7 +15,7 @@
from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.http import get_http_session
from authentik.root.middleware import ClientIPMiddleware
-from authentik.stages.captcha.models import CaptchaStage
+from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage
LOGGER = get_logger()
PLAN_CONTEXT_CAPTCHA = "captcha"
@@ -35,17 +35,23 @@ class CaptchaChallenge(WithUserInfoChallenge):
def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: str | None = None):
"""Validate captcha token"""
+ payload = {
+ "secret": key or stage.private_key,
+ "response": token,
+ "remoteip": remote_ip,
+ }
+ body_kwargs = (
+ {"json": payload}
+ if stage.request_content_type == CaptchaRequestContentType.JSON
+ else {"data": payload}
+ )
try:
response = get_http_session().post(
stage.api_url,
headers={
- "Content-type": "application/x-www-form-urlencoded",
- },
- data={
- "secret": key or stage.private_key,
- "response": token,
- "remoteip": remote_ip,
+ "Content-Type": stage.request_content_type,
},
+ **body_kwargs,
)
response.raise_for_status()
data = response.json()
diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py
index 41bceb9f43a3..39a7b4a32e57 100644
--- a/authentik/stages/captcha/tests.py
+++ b/authentik/stages/captcha/tests.py
@@ -10,7 +10,7 @@
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
-from authentik.stages.captcha.models import CaptchaStage
+from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage
from authentik.stages.captcha.stage import (
PLAN_CONTEXT_CAPTCHA_PRIVATE_KEY,
PLAN_CONTEXT_CAPTCHA_SITE_KEY,
@@ -56,6 +56,39 @@ def test_valid(self, mock: Mocker):
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
+ self.assertEqual(
+ mock.request_history[0].headers["Content-Type"],
+ CaptchaRequestContentType.FORM,
+ )
+ self.assertIn("response=PASSED", mock.request_history[0].text)
+
+ @Mocker()
+ def test_valid_json_content_type(self, mock: Mocker):
+ """Test valid captcha with JSON verification request"""
+ self.stage.request_content_type = CaptchaRequestContentType.JSON
+ self.stage.save()
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": 0.5,
+ },
+ )
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+ {"token": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
+ self.assertEqual(
+ mock.request_history[0].headers["Content-Type"],
+ CaptchaRequestContentType.JSON,
+ )
+ self.assertEqual(mock.request_history[0].json()["response"], "PASSED")
@Mocker()
def test_valid_override(self, mock: Mocker):
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 38e266f92424..a32288d3ba4a 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -15160,6 +15160,14 @@
"minLength": 1,
"title": "Api url"
},
+ "request_content_type": {
+ "type": "string",
+ "enum": [
+ "application/x-www-form-urlencoded",
+ "application/json"
+ ],
+ "title": "Request content type"
+ },
"interactive": {
"type": "boolean",
"title": "Interactive"
diff --git a/packages/client-ts/src/models/CaptchaStage.ts b/packages/client-ts/src/models/CaptchaStage.ts
index 2c6b18c9dc50..13430ab177fa 100644
--- a/packages/client-ts/src/models/CaptchaStage.ts
+++ b/packages/client-ts/src/models/CaptchaStage.ts
@@ -14,6 +14,11 @@
import type { FlowSet } from "./FlowSet";
import { FlowSetFromJSON } from "./FlowSet";
+import type { RequestContentTypeEnum } from "./RequestContentTypeEnum";
+import {
+ RequestContentTypeEnumFromJSON,
+ RequestContentTypeEnumToJSON,
+} from "./RequestContentTypeEnum";
/**
* CaptchaStage Serializer
@@ -81,6 +86,12 @@ export interface CaptchaStage {
* @memberof CaptchaStage
*/
apiUrl?: string;
+ /**
+ *
+ * @type {RequestContentTypeEnum}
+ * @memberof CaptchaStage
+ */
+ requestContentType?: RequestContentTypeEnum;
/**
*
* @type {boolean}
@@ -141,6 +152,10 @@ export function CaptchaStageFromJSONTyped(json: any, ignoreDiscriminator: boolea
publicKey: json["public_key"],
jsUrl: json["js_url"] == null ? undefined : json["js_url"],
apiUrl: json["api_url"] == null ? undefined : json["api_url"],
+ requestContentType:
+ json["request_content_type"] == null
+ ? undefined
+ : RequestContentTypeEnumFromJSON(json["request_content_type"]),
interactive: json["interactive"] == null ? undefined : json["interactive"],
scoreMinThreshold:
json["score_min_threshold"] == null ? undefined : json["score_min_threshold"],
@@ -171,6 +186,7 @@ export function CaptchaStageToJSONTyped(
public_key: value["publicKey"],
js_url: value["jsUrl"],
api_url: value["apiUrl"],
+ request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]),
interactive: value["interactive"],
score_min_threshold: value["scoreMinThreshold"],
score_max_threshold: value["scoreMaxThreshold"],
diff --git a/packages/client-ts/src/models/CaptchaStageRequest.ts b/packages/client-ts/src/models/CaptchaStageRequest.ts
index 1c6802216861..37792dc7ef2c 100644
--- a/packages/client-ts/src/models/CaptchaStageRequest.ts
+++ b/packages/client-ts/src/models/CaptchaStageRequest.ts
@@ -12,6 +12,12 @@
* Do not edit the class manually.
*/
+import type { RequestContentTypeEnum } from "./RequestContentTypeEnum";
+import {
+ RequestContentTypeEnumFromJSON,
+ RequestContentTypeEnumToJSON,
+} from "./RequestContentTypeEnum";
+
/**
* CaptchaStage Serializer
* @export
@@ -48,6 +54,12 @@ export interface CaptchaStageRequest {
* @memberof CaptchaStageRequest
*/
apiUrl?: string;
+ /**
+ *
+ * @type {RequestContentTypeEnum}
+ * @memberof CaptchaStageRequest
+ */
+ requestContentType?: RequestContentTypeEnum;
/**
*
* @type {boolean}
@@ -101,6 +113,10 @@ export function CaptchaStageRequestFromJSONTyped(
privateKey: json["private_key"],
jsUrl: json["js_url"] == null ? undefined : json["js_url"],
apiUrl: json["api_url"] == null ? undefined : json["api_url"],
+ requestContentType:
+ json["request_content_type"] == null
+ ? undefined
+ : RequestContentTypeEnumFromJSON(json["request_content_type"]),
interactive: json["interactive"] == null ? undefined : json["interactive"],
scoreMinThreshold:
json["score_min_threshold"] == null ? undefined : json["score_min_threshold"],
@@ -129,6 +145,7 @@ export function CaptchaStageRequestToJSONTyped(
private_key: value["privateKey"],
js_url: value["jsUrl"],
api_url: value["apiUrl"],
+ request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]),
interactive: value["interactive"],
score_min_threshold: value["scoreMinThreshold"],
score_max_threshold: value["scoreMaxThreshold"],
diff --git a/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts b/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts
index b616986c41c6..b2522615bb88 100644
--- a/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts
+++ b/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts
@@ -12,6 +12,12 @@
* Do not edit the class manually.
*/
+import type { RequestContentTypeEnum } from "./RequestContentTypeEnum";
+import {
+ RequestContentTypeEnumFromJSON,
+ RequestContentTypeEnumToJSON,
+} from "./RequestContentTypeEnum";
+
/**
* CaptchaStage Serializer
* @export
@@ -48,6 +54,12 @@ export interface PatchedCaptchaStageRequest {
* @memberof PatchedCaptchaStageRequest
*/
apiUrl?: string;
+ /**
+ *
+ * @type {RequestContentTypeEnum}
+ * @memberof PatchedCaptchaStageRequest
+ */
+ requestContentType?: RequestContentTypeEnum;
/**
*
* @type {boolean}
@@ -100,6 +112,10 @@ export function PatchedCaptchaStageRequestFromJSONTyped(
privateKey: json["private_key"] == null ? undefined : json["private_key"],
jsUrl: json["js_url"] == null ? undefined : json["js_url"],
apiUrl: json["api_url"] == null ? undefined : json["api_url"],
+ requestContentType:
+ json["request_content_type"] == null
+ ? undefined
+ : RequestContentTypeEnumFromJSON(json["request_content_type"]),
interactive: json["interactive"] == null ? undefined : json["interactive"],
scoreMinThreshold:
json["score_min_threshold"] == null ? undefined : json["score_min_threshold"],
@@ -128,6 +144,7 @@ export function PatchedCaptchaStageRequestToJSONTyped(
private_key: value["privateKey"],
js_url: value["jsUrl"],
api_url: value["apiUrl"],
+ request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]),
interactive: value["interactive"],
score_min_threshold: value["scoreMinThreshold"],
score_max_threshold: value["scoreMaxThreshold"],
diff --git a/packages/client-ts/src/models/RequestContentTypeEnum.ts b/packages/client-ts/src/models/RequestContentTypeEnum.ts
new file mode 100644
index 000000000000..e287dc0fa258
--- /dev/null
+++ b/packages/client-ts/src/models/RequestContentTypeEnum.ts
@@ -0,0 +1,58 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * authentik
+ * Making authentication simple.
+ *
+ * The version of the OpenAPI document: 2026.8.0-rc1
+ * Contact: hello@goauthentik.io
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ */
+export const RequestContentTypeEnum = {
+ ApplicationXWwwFormUrlencoded: "application/x-www-form-urlencoded",
+ ApplicationJson: "application/json",
+ UnknownDefaultOpenApi: "11184809",
+} as const;
+export type RequestContentTypeEnum =
+ (typeof RequestContentTypeEnum)[keyof typeof RequestContentTypeEnum];
+
+export function instanceOfRequestContentTypeEnum(value: any): boolean {
+ for (const key in RequestContentTypeEnum) {
+ if (Object.prototype.hasOwnProperty.call(RequestContentTypeEnum, key)) {
+ if (RequestContentTypeEnum[key as keyof typeof RequestContentTypeEnum] === value) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+export function RequestContentTypeEnumFromJSON(json: any): RequestContentTypeEnum {
+ return RequestContentTypeEnumFromJSONTyped(json, false);
+}
+
+export function RequestContentTypeEnumFromJSONTyped(
+ json: any,
+ ignoreDiscriminator: boolean,
+): RequestContentTypeEnum {
+ return json as RequestContentTypeEnum;
+}
+
+export function RequestContentTypeEnumToJSON(value?: RequestContentTypeEnum | null): any {
+ return value as any;
+}
+
+export function RequestContentTypeEnumToJSONTyped(
+ value: any,
+ ignoreDiscriminator: boolean,
+): RequestContentTypeEnum {
+ return value as RequestContentTypeEnum;
+}
diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts
index da4090a4cf8c..3f9fc870ef19 100644
--- a/packages/client-ts/src/models/index.ts
+++ b/packages/client-ts/src/models/index.ts
@@ -713,6 +713,7 @@ export * from "./RelatedRule";
export * from "./Reputation";
export * from "./ReputationPolicy";
export * from "./ReputationPolicyRequest";
+export * from "./RequestContentTypeEnum";
export * from "./Review";
export * from "./ReviewRequest";
export * from "./ReviewerGroup";
diff --git a/schema.yml b/schema.yml
index 9714bf47c022..bc78dd4af27d 100644
--- a/schema.yml
+++ b/schema.yml
@@ -36339,6 +36339,8 @@ components:
type: string
api_url:
type: string
+ request_content_type:
+ $ref: '#/components/schemas/RequestContentTypeEnum'
interactive:
type: boolean
score_min_threshold:
@@ -36384,6 +36386,8 @@ components:
api_url:
type: string
minLength: 1
+ request_content_type:
+ $ref: '#/components/schemas/RequestContentTypeEnum'
interactive:
type: boolean
score_min_threshold:
@@ -48287,6 +48291,8 @@ components:
api_url:
type: string
minLength: 1
+ request_content_type:
+ $ref: '#/components/schemas/RequestContentTypeEnum'
interactive:
type: boolean
score_min_threshold:
@@ -53868,6 +53874,11 @@ components:
minimum: -2147483648
required:
- name
+ RequestContentTypeEnum:
+ enum:
+ - application/x-www-form-urlencoded
+ - application/json
+ type: string
Review:
type: object
description: |-
diff --git a/web/src/admin/stages/captcha/CaptchaStageForm.ts b/web/src/admin/stages/captcha/CaptchaStageForm.ts
index bb184313c6b1..f31b952bd6c7 100644
--- a/web/src/admin/stages/captcha/CaptchaStageForm.ts
+++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts
@@ -14,9 +14,11 @@ import { SlottedTemplateResult } from "#elements/types";
import { BaseStageForm } from "#admin/stages/BaseStageForm";
import {
CAPTCHA_PROVIDERS,
+ CAPTCHA_REQUEST_CONTENT_TYPES,
CaptchaProviderKey,
CaptchaProviderKeys,
CaptchaProviderPreset,
+ deriveCapSiteVerifyURL,
detectProviderFromInstance,
pluckFormValues,
} from "#admin/stages/captcha/shared";
@@ -35,6 +37,10 @@ import { customElement, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { ifDefined } from "lit/directives/if-defined.js";
+type CaptchaStageFormRequest = (CaptchaStageRequest | PatchedCaptchaStageRequest) & {
+ capEndpoint?: string;
+};
+
@customElement("ak-stage-captcha-form")
export class CaptchaStageForm extends BaseStageForm
${formatDescription()}
` + : null; + const providerLink = + formatAPISource && keyURL + ? html`+ ${msg( + "Content-Type used for server-side verification. Cap requires JSON; most other providers use form-encoded requests.", + )} +
+