How do you integrate hCaptcha with a Flutter app?#
Following hCaptcha's Flutter WebView integration example, host a small hCaptcha HTML page on a domain configured for your sitekey, open it in a Flutter WebView, and pass the successful token back to Dart through a JavaScript channel. Send the token to your backend and accept the protected action only after hCaptcha Siteverify returns success: true.
This guide updates hCaptcha's original Flutter WebView example for the current webview_flutter API. hCaptcha does not publish an official Flutter SDK.
Keep verification consistent with your Flutter app#
- Ask users to complete fewer visual tasks. hCaptcha Pro's 99.9% Passive mode reduces challenge interruptions in the hosted WebView flow, helping people stay focused on their app action.
- Style the challenge to fit the app. Pro's custom themes let you match challenge colors and styles to your interface. Apply the theme on the hosted hCaptcha page that your Flutter WebView loads.
New Pro sitekeys use 99.9% Passive by default. For an existing sitekey upgraded to Pro, select that mode under Behavior in the hCaptcha dashboard.
Before you start#
These instructions were last validated on September 22, 2026 with webview_flutter 4.14.1.
You need:
- A Flutter 3.38+ app using Dart 3.10+ and targeting Android SDK 24+ or iOS 13+.
- An HTTPS domain where you can host the challenge page.
- A backend endpoint for the protected action.
- An hCaptcha account with a sitekey and matching secret.
- A secure backend secret store and outbound HTTPS access to hCaptcha.
Review hCaptcha's official Flutter example, mobile SDK guidance, and integration catalog entry. The WebView dependency is maintained by the Flutter team; see its package page and source repository. The hCaptcha integrations-list repository records the broader catalog.
Create your hCaptcha credentials#
- Start with hCaptcha Pro for fewer challenges and adaptive protection on your Flutter verification flow, or use existing compatible hCaptcha credentials.
- Create a sitekey and associate the challenge page's hostname with it.
- Put the public sitekey in the hosted HTML page.
- Store the matching secret only in protected backend configuration.
The sitekey is public. The secret must never appear in the HTML page, Dart code, mobile app configuration, or application package.
Host the hCaptcha challenge page#
Create a page such as https://app.example.com/flutter-hcaptcha.html:
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
function captchaSolved(token) {
Captcha.postMessage(JSON.stringify({ type: "success", token }));
}
function captchaExpired() {
Captcha.postMessage(JSON.stringify({ type: "expired" }));
}
function captchaError(error) {
Captcha.postMessage(JSON.stringify({ type: "error", error }));
}
</script>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
</head>
<body>
<div
class="h-captcha"
data-sitekey="YOUR_SITEKEY"
data-callback="captchaSolved"
data-expired-callback="captchaExpired"
data-error-callback="captchaError"
></div>
</body>
</html>
Serve the page over HTTPS from the hostname associated with the sitekey. Keep it narrowly scoped: it should load hCaptcha, report explicit outcomes, and contain no secret.
Add the Flutter WebView#
Add the current reviewed dependency to pubspec.yaml:
dependencies:
webview_flutter: 4.14.1
Create a screen that enables JavaScript, registers the same Captcha channel used by the hosted page, and loads the HTTPS URL:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class CaptchaScreen extends StatefulWidget {
const CaptchaScreen({super.key, required this.onToken});
final Future<void> Function(String token) onToken;
@override
State<CaptchaScreen> createState() => _CaptchaScreenState();
}
class _CaptchaScreenState extends State<CaptchaScreen> {
late final WebViewController controller;
void showVerificationError(Object _) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Verification failed. Please try again.')),
);
}
Future<void> resetChallenge() async {
await controller.reload();
}
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) {
final uri = Uri.parse(request.url);
final hostedOrigin = uri.scheme == 'https' &&
uri.origin == 'https://app.example.com';
final captchaFrame = !request.isMainFrame &&
uri.scheme == 'https' &&
(uri.host == 'hcaptcha.com' ||
uri.host.endsWith('.hcaptcha.com'));
return hostedOrigin || captchaFrame
? NavigationDecision.navigate
: NavigationDecision.prevent;
},
),
)
..addJavaScriptChannel(
'Captcha',
onMessageReceived: (message) async {
try {
final decoded = jsonDecode(message.message);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid hCaptcha message');
}
final type = decoded['type'];
final token = decoded['token'];
if (type == 'success' && token is String && token.isNotEmpty) {
try {
await widget.onToken(token);
if (mounted) Navigator.of(context).pop();
} catch (error) {
showVerificationError(error);
await resetChallenge();
}
return;
}
showVerificationError(decoded);
} on FormatException {
showVerificationError({'type': 'invalid-message'});
} catch (_) {
showVerificationError({'type': 'verification-failed'});
}
},
)
..loadRequest(Uri.parse('https://app.example.com/flutter-hcaptcha.html'));
}
@override
Widget build(BuildContext context) {
return Scaffold(body: SafeArea(child: WebViewWidget(controller: controller)));
}
}
Replace the example URL and hostname check together, and provide application-specific UI for expiration, malformed channel messages, backend failures, and widget errors. The onToken callback must complete only after the backend accepts the protected request and throw when the request is rejected or unavailable. The error branch keeps the screen open, displays an error, and reloads the hosted challenge so the retry uses a fresh token. The delegate restricts top-level navigation to the hosted origin while allowing HTTPS hCaptcha subframes. Blocking all other hosts without checking isMainFrame can block the challenge itself. Review additional origins and test the actual iOS and Android WebViews before release.
Open the challenge from a protected action#
Push the WebView screen when the user submits a protected action:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CaptchaScreen(
onToken: (token) => sendRequestToBackend(hcaptchaToken: token),
),
),
);
Consume the token immediately. Tokens are single-use and short-lived, so request a new token after expiration, an unsuccessful backend response, or a retry.
Verify the token on your backend#
The backend must reject missing tokens, then send a URL-encoded POST to https://api.hcaptcha.com/siteverify. Include the server-held secret and the Flutter token as response. The remoteip parameter is optional. We recommend sending it for improved verification accuracy and Enterprise risk scores when the backend derives the visitor's IP address from a reviewed, trusted hosting or proxy configuration; otherwise omit it. Also send the expected sitekey, which we recommend to prevent a token issued for another sitekey from being redeemed for this flow. Continue only when the response contains success: true.
Follow the server-side verification documentation. Receiving a JavaScript-channel message does not authorize the protected action.
Test the complete Flutter flow#
- Confirm the challenge page loads only from the expected HTTPS origin.
- Confirm successful tokens work once and reused or expired tokens fail.
- Test expiration, challenge errors, slow networks, offline mode, and retries.
- Test backgrounding, navigation, screen teardown, and repeated verification.
- Restrict WebView navigation and reject malformed channel messages.
- Build and test supported Android and iOS targets on physical devices.
- Test the exact Flutter, Dart,
webview_flutter, Xcode, and Android toolchain versions.
Troubleshoot common Flutter problems#
The challenge page does not load
Confirm the device has network access, the page is served over HTTPS, JavaScript is enabled, and the selected WebView package supports the target platform.
No token reaches Dart
The HTML channel name and Dart channel name must both be Captcha. Confirm the page calls Captcha.postMessage(...) and that the message contains valid JSON.
hCaptcha rejects the hostname
Serve the page from a hostname associated with the sitekey. A local file or an unrelated origin does not reproduce the documented hosted-page pattern.
The backend rejects a successful token
Send the exact returned token promptly, use the matching server-held secret, and submit a URL-encoded Siteverify request. Do not reuse the token.
Frequently asked questions#
Is there an official hCaptcha Flutter SDK?
No. hCaptcha publishes and links to a Flutter implementation example that uses a WebView. This guide updates that pattern for the current reviewed WebView package.
Why does the Flutter integration use a hosted page?
The hosted page gives hCaptcha a real HTTPS hostname associated with the sitekey and provides a small bridge from hCaptcha's JavaScript callback to Dart.
Does the WebView verify the token?
No. Your backend must send every token and the private secret to Siteverify before accepting the protected request.
Can the hCaptcha secret be stored in the Flutter app?
No. Mobile application packages can be inspected. Keep the secret on the backend and expose only the sitekey to the hosted page.
Can this pattern protect both Android and iOS apps?
Yes, subject to the WebView package's platform support. Validate the complete flow on each supported OS and physical-device combination.
Sources and references
- hCaptcha custom themes hCaptcha
- hCaptcha Pro product overview hCaptcha
- Implementing hCaptcha in your Flutter App hCaptcha
- hCaptcha mobile app SDKs hCaptcha
- hCaptcha integrations hCaptcha
- webview_flutter package Flutter team
- webview_flutter source Flutter team
- Verify the user response server-side hCaptcha
- hCaptcha integrations list source hCaptcha
- hCaptcha Pro hCaptcha