Server-Side reCAPTCHA for Static Sites with Lambda
I was working on a small client’s website - a static site behind CloudFront with a simple contact form. Google had sent them an email saying their reCAPTCHA setup was incomplete. The checkbox rendered fine, users could tick it, and emails were being sent. But nobody was actually verifying the token with Google.
The problem
The contact form used EmailJS to send emails directly from the browser. reCAPTCHA produced a token when the user solved the checkbox, and the frontend checked that the token existed before calling EmailJS. That’s it. No server-side verification.
This is what the flow looked like:
Browser → reCAPTCHA checkbox → token exists? yes → call EmailJS directly
A bot could skip the checkbox entirely and call EmailJS using the credentials visible in the page source. The reCAPTCHA token was never sent to Google for assessment, so Google had no record of any verification happening.
Google recently restructured parts of their reCAPTCHA docs under “Google Cloud Fraud Defense”. In the reCAPTCHA Enterprise docs, website keys are now framed around key types such as SCORE, CHECKBOX, and POLICY_BASED_CHALLENGE. The existing key was a CHECKBOX type and there was no reason to change it as the form gets low traffic and a visible challenge is fine for the audience.
What proper verification looks like
The fix requires a backend. The browser can’t verify its own token - that would defeat the purpose. The correct flow:
- User fills in the form and solves the checkbox. The browser gets a short-lived token.
- The browser POSTs the form data and the token to your own API.
- Your API sends the token to Google’s assessment endpoint.
- Google replies with a single JSON response containing
tokenProperties.validandriskAnalysis.score. - If the token is valid and the score is acceptable, your API sends the email.
- The browser gets a success or error response.
Browser → POST form + token → API Gateway → Lambda
├── Google assessment API (verify token)
└── Email provider REST API (send mail)
The assessment is one API call, not a back-and-forth. You POST the token to Google, they return the verdict, you decide locally what to do with it.
Architecture choice
The site is a static Next.js export, so there’s no server to add a route to. I went with API Gateway (HTTP API) and a Python Lambda because the client’s CDK stack was already Python and the whole thing is one POST endpoint.
There were two routing options:
| Approach | Pros | Cons |
|---|---|---|
Route /api/* through CloudFront to API Gateway | Same origin, no CORS needed | Extra CloudFront behaviour config, harder to debug |
| Expose API Gateway directly, configure CORS | Simple, fewer moving parts | Cross-origin requests, need explicit CORS rules |
For a single endpoint, the direct approach won. CORS is a few lines of config.
EmailJS stayed as the mail provider. It was already configured with templates and the client was happy with it. The change was moving the EmailJS call from the browser into the Lambda, so credentials are no longer in the page source and mail only sends after a valid assessment.
The CDK stack additions
The interesting parts of the stack. Context values are passed at deploy time (cdk deploy -c key=value) so nothing is hardcoded:
from aws_cdk import (
Duration, Stack, CfnOutput, Fn,
aws_lambda as _lambda,
aws_ssm as ssm,
)
from aws_cdk import aws_apigatewayv2 as apigwv2
from aws_cdk.aws_apigatewayv2_integrations import HttpLambdaIntegration
# ... inside the stack constructor, after the CloudFront distribution ...
# Derive the allowed origin from the distribution
allowed_origin = f"https://{distribution.distribution_domain_name}"
# Extra origins for custom domains (comma-separated context value)
extra_origins_raw = self.node.try_get_context("cors_extra_origins") or ""
extra_origins = [o.strip() for o in extra_origins_raw.split(",") if o.strip()]
all_origins = [allowed_origin, "http://localhost:3000"] + extra_origins
The Lambda and API Gateway
contact_fn = _lambda.Function(
self, "ContactHandler",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="handler.handler",
code=_lambda.Code.from_asset("lambda/contact"),
timeout=Duration.seconds(30),
memory_size=256,
environment={
"RECAPTCHA_SITE_KEY": recaptcha_site_key,
"GCP_PROJECT_ID": gcp_project_id,
"GCP_API_KEY": gcp_api_key,
"EMAILJS_SERVICE_ID": emailjs_service_id,
"EMAILJS_TEMPLATE_ID": emailjs_template_id,
"EMAILJS_PUBLIC_KEY": emailjs_public_key,
"EMAILJS_PRIVATE_KEY": emailjs_private_key,
"CORS_ALLOWED_ORIGINS": Fn.join(",", all_origins),
"RECAPTCHA_SCORE_THRESHOLD": "0.5",
},
)
api = apigwv2.HttpApi(
self, "ContactApi",
cors_preflight=apigwv2.CorsPreflightOptions(
allow_origins=all_origins,
allow_methods=[apigwv2.CorsHttpMethod.POST],
allow_headers=["Content-Type"],
max_age=Duration.hours(1),
),
)
api.add_routes(
path="/api/contact",
methods=[apigwv2.HttpMethod.POST],
integration=HttpLambdaIntegration("ContactIntegration", contact_fn),
)
# Throttle to prevent abuse
stage = api.default_stage.node.default_child
stage.add_property_override("DefaultRouteSettings.ThrottlingBurstLimit", 10)
stage.add_property_override("DefaultRouteSettings.ThrottlingRateLimit", 5)
For larger or more sensitive environments, use AWS Secrets Manager or Parameter Store instead of storing secrets directly in Lambda environment variables. Here, environment variables were used to keep the deployment simple for the project scope.
The Lambda handler
Though Google provides a library (google-cloud-recaptchaenterprise) to manage reCAPTCHA, in this case I decided to just use urllib.request and send the requests directly to Google’s REST API. This allows me to keep things simpler and avoid using Lambda layers or similar methods for the external dependencies.
The assessment call
import json
import urllib.request
# Simplified example of the reCAPTCHA verification approach
ASSESS_URL = (
f"https://recaptchaenterprise.googleapis.com/v1/"
f"projects/{GCP_PROJECT_ID}/assessments?key={GCP_API_KEY}"
)
def verify_recaptcha(token, site_key, source_ip, user_agent):
body = json.dumps({
"event": {
"token": token,
"siteKey": site_key,
"userIpAddress": source_ip,
"userAgent": user_agent,
}
}).encode()
req = urllib.request.Request(
ASSESS_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode())
token_props = result.get("tokenProperties", {})
if not token_props.get("valid"):
invalid_reason = token_props.get("invalidReason", "UNKNOWN")
return False, f"invalid token: {invalid_reason}"
score = result.get("riskAnalysis", {}).get("score", 0.0)
if score < SCORE_THRESHOLD:
return False, f"score too low: {score}"
return True, None
Google returns everything in one response. You check tokenProperties.valid first (catches expired, reused, or forged tokens), then look at the score. For a checkbox key on a simple form, valid == True is the main gate. The score gives you additional signal you can log or tighten later. You can define your own SCORE_THRESHOLD and adjust it over time as needed.
Add CORS response headers
def _cors_allowlist() -> list[str]:
raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
return [o.strip() for o in raw.split(",") if o.strip()]
def _cors_origin_for_request(event: dict) -> str | None:
allowlist = _cors_allowlist()
hdrs = event.get("headers") or {}
origin = hdrs.get("origin") or hdrs.get("Origin", "") or hdrs.get("ORIGIN", "")
if origin and origin in allowlist:
return origin
if origin:
logger.warning("CORS: origin not in allowlist")
return None
def _response_headers(event: dict, base_headers: dict) -> dict:
out = {**base_headers}
cors = _cors_origin_for_request(event)
if cors:
out["Access-Control-Allow-Origin"] = cors
return out
# then call it in handler like
# ...
base = {
"Content-Type": "application/json",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS",
}
headers = _response_headers(event, base)
The CORS handling checks the incoming Origin header against the comma-separated allowlist in the CORS_ALLOWED_ORIGINS environment variable. If it matches, the response includes Access-Control-Allow-Origin with that specific origin. If not, the header is omitted and the browser blocks the response.
EmailJS REST API call
def send_email(form_data, config):
body = {
"service_id": config["service_id"],
"template_id": config["template_id"],
"user_id": config["public_key"],
"accessToken": config["private_key"],
"template_params": {
"name": form_data["name"],
"email": form_data["email"],
"phone": form_data.get("phone", ""),
"message": form_data["message"],
},
}
req = urllib.request.Request(
"https://api.emailjs.com/api/v1.0/email/send",
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"User-Agent": "MyApp-ContactForm/1.0",
},
method="POST",
)
urllib.request.urlopen(req, timeout=10)
The main detail to note here is the "User-Agent": "MyApp-ContactForm/1.0" header. It helped avoid Cloudflare error 1010 when calling the EmailJS REST API. See the Cloudflare error 1010 section for more context.
The frontend change
The old code used react-google-recaptcha (which loads the classic api.js) and called EmailJS directly. The new version loads the Enterprise script and POSTs to the backend instead:
// Custom component loads enterprise.js and renders the checkbox
// using grecaptcha.enterprise.render() with a callback
const handleSubmit = async () => {
const res = await fetch(process.env.NEXT_PUBLIC_CONTACT_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
email: formData.email,
phone: formData.phone,
message: formData.message,
recaptchaToken,
}),
});
const data = await res.json();
// handle success/error
};
The frontend .env shrinks to two variables: the reCAPTCHA site key (for the widget) and the API Gateway URL. EmailJS credentials are gone from the browser entirely.
Things that tripped me up
CORS and custom domains
The CDK stack derived the allowed origin from distribution.distribution_domain_name, which gives you something like d123abc.cloudfront.net. But visitors use the custom domain (www.example.com). Browsers compare the Origin header literally, so the preflight passed at the HTTP level but the browser blocked the response because Access-Control-Allow-Origin didn’t match.
Fix: pass custom domains as an extra context value at deploy time and include them in both the API Gateway CORS config and the Lambda’s origin allowlist.
Cloudflare error 1010
EmailJS sits behind Cloudflare. Lambda’s default urllib User-Agent (Python-urllib/3.12) gets blocked by Cloudflare’s bot detection. The same request works fine from a local curl because curl sends a normal User-Agent. Setting any non-default User-Agent header on the outbound request fixed it.
EmailJS private key
Enabling “Allow EmailJS API for non-browser applications” in the EmailJS dashboard puts the API in strict mode. Every request must include the accessToken (private key) field. Without it you get a 403 with the message “API access in strict mode, but no Private Key was provided”.
Google Cloud API key
The Lambda runs on AWS, not GCP, so it can’t use Google’s default service account authentication. You need a GCP API key scoped to the reCAPTCHA Enterprise API. The key is passed as a query parameter on the assessment URL, so restrict it to that API rather than leaving it broadly usable. Worth checking the prerequisites too: the GCP project needs the reCAPTCHA Enterprise API enabled, and the reCAPTCHA site key needs the website’s domains in its allowed domain list.
Testing
Test the API Gateway endpoint directly with curl:
curl -i -X POST "https://YOUR_API_ID.execute-api.REGION.amazonaws.com/api/contact" \
-H "Content-Type: application/json" \
-H "Origin: https://www.example.com" \
-d '{
"name": "Test",
"email": "test@example.com",
"message": "Hello",
"recaptchaToken": "invalid-token"
}'
You should get a 400 with a reCAPTCHA verification failure. A 200 only happens with a real token from a live page load (tokens expire after two minutes and are single-use).
To isolate email provider issues, test EmailJS separately:
curl -i -X POST "https://api.emailjs.com/api/v1.0/email/send" \
-H "Content-Type: application/json" \
-H "User-Agent: MyApp/1.0" \
-d '{
"service_id": "your_service_id",
"template_id": "your_template_id",
"user_id": "your_public_key",
"accessToken": "your_private_key",
"template_params": {
"name": "Test",
"email": "test@example.com",
"message": "Test from curl"
}
}'
Summary
As a result, the contact form submissions go through API Gateway to a Lambda that verifies the reCAPTCHA token with Google, checks the score, and only then sends mail via EmailJS. The browser no longer has access to email credentials, and bots that skip the checkbox get rejected at the assessment step.
The infrastructure additions are small - one Lambda, one HTTP API route and CORS configuration. Most of the time went into troubleshooting CORS origin mismatches with custom domains, Cloudflare blocking default Python User-Agents, and EmailJS strict mode requiring a private key that I hadn’t realised was needed until the 403 showed up in CloudWatch.
Questions or thoughts? Feel free to reach out.
Get in touch →