Cloudflare Turnstile · Angular SSR · FastAPI

Cloudflare Turnstile with Angular and FastAPI to protect a bot interaction

Turnstile is useful when a public Angular page must send a message, launch a bot workflow or call a sensitive backend action without opening the door to automated abuse.

The safe architecture is simple: Angular renders the widget and sends the temporary token; FastAPI validates that token with Cloudflare before the bot does anything.

Security rule

The secret key never belongs in Angular, Git, screenshots or tutorials. If a secret has been shared, rotate it in Cloudflare and deploy the new value only as a backend environment variable.

This article belongs to the security and captcha topic hub.

1. Architecture

The browser is not trusted. It can only obtain a short-lived Turnstile token. FastAPI verifies that token server-side, applies rate limits and then runs the bot action.

1

Angular frontend

Public site key used in the Angular example: 0x4AAAAAADL_SO30Hjut98cd

2

FastAPI backend

Receives turnstile_token, validates length, rate limits and the internal header.

3

Cloudflare Siteverify

Confirms that the token is authentic, not expired and not reused.

4

Protected bot action

Runs only after FastAPI receives a success response.

2. Angular integration

In Angular SSR, load the Turnstile script only in the browser. The widget returns a token through a callback; that token is submitted together with the form payload.

environment

export const environment = {
  production: true,
  apiBaseUrl: '/api',
  turnstileSiteKey: '0x4AAAAAADL_SO30Hjut98cd'
};

TurnstileLoaderService

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';

declare global {
  interface Window {
    turnstile?: {
      render: (container: HTMLElement, options: Record<string, unknown>) => string;
      reset: (widgetId?: string) => void;
      remove: (widgetId?: string) => void;
    };
  }
}

@Injectable({ providedIn: 'root' })
export class TurnstileLoaderService {
  private loading?: Promise<void>;

  constructor(
    @Inject(DOCUMENT) private readonly document: Document,
    @Inject(PLATFORM_ID) private readonly platformId: object,
  ) {}

  load(): Promise<void> {
    if (!isPlatformBrowser(this.platformId)) {
      return Promise.resolve();
    }

    if (window.turnstile) {
      return Promise.resolve();
    }

    if (this.loading) {
      return this.loading;
    }

    this.loading = new Promise((resolve, reject) => {
      const script = this.document.createElement('script');
      script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
      script.async = true;
      script.defer = true;
      script.onload = () => resolve();
      script.onerror = () => reject(new Error('Turnstile could not be loaded'));
      this.document.head.appendChild(script);
    });

    return this.loading;
  }
}

Form component

import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../environments/environment';
import { TurnstileLoaderService } from './turnstile-loader.service';

@Component({
  selector: 'app-bot-form',
  templateUrl: './bot-form.component.html',
})
export class BotFormComponent implements AfterViewInit, OnDestroy {
  @ViewChild('turnstileContainer') turnstileContainer?: ElementRef<HTMLElement>;

  private readonly http = inject(HttpClient);
  private readonly turnstileLoader = inject(TurnstileLoaderService);
  private widgetId?: string;

  message = '';
  turnstileToken = '';
  errorMessage = '';
  isSending = false;

  async ngAfterViewInit(): Promise<void> {
    await this.turnstileLoader.load();
    if (!this.turnstileContainer?.nativeElement || !window.turnstile) {
      return;
    }

    this.widgetId = window.turnstile.render(this.turnstileContainer.nativeElement, {
      sitekey: environment.turnstileSiteKey,
      action: 'bot-demo',
      callback: (token: string) => {
        this.turnstileToken = token;
        this.errorMessage = '';
      },
      'expired-callback': () => {
        this.turnstileToken = '';
        this.errorMessage = 'Turnstile token expired. Please verify again.';
      },
      'error-callback': () => {
        this.turnstileToken = '';
        this.errorMessage = 'Turnstile could not complete the verification.';
      },
    });
  }

  submit(): void {
    if (!this.turnstileToken || this.isSending) {
      return;
    }

    this.isSending = true;
    this.http.post(`${environment.apiBaseUrl}/bot/turnstile-demo`, {
      message: this.message,
      turnstile_token: this.turnstileToken,
    }).subscribe({
      next: () => this.resetTurnstile(),
      error: () => {
        this.errorMessage = 'The secure bot request could not be completed.';
        this.resetTurnstile();
      },
    }).add(() => {
      this.isSending = false;
    });
  }

  ngOnDestroy(): void {
    if (window.turnstile && this.widgetId) {
      window.turnstile.remove(this.widgetId);
    }
  }

  private resetTurnstile(): void {
    this.turnstileToken = '';
    window.turnstile?.reset(this.widgetId);
  }
}

HTML template

<form (ngSubmit)="submit()" class="bot-form">
  <label for="message">Message for the bot</label>
  <textarea
    id="message"
    name="message"
    [(ngModel)]="message"
    maxlength="500"
    required
  ></textarea>

  <div #turnstileContainer></div>

  @if (errorMessage) {
    <p class="error">{{ errorMessage }}</p>
  }

  <button type="submit" [disabled]="!turnstileToken || isSending">
    Send secure request
  </button>
</form>

3. FastAPI validation

The backend posts the token to Cloudflare Siteverify. A success response is required before the protected bot endpoint returns useful data.

import os
from typing import Any

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field

app = FastAPI()

TURNSTILE_SECRET_KEY = os.getenv('TURNSTILE_SECRET_KEY', '')
TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'

class TurnstileBotRequest(BaseModel):
    message: str = Field(..., min_length=3, max_length=500)
    turnstile_token: str = Field(..., min_length=20, max_length=4096)

async def verify_turnstile_token(token: str, remote_ip: str | None = None) -> dict[str, Any]:
    if not TURNSTILE_SECRET_KEY:
        raise HTTPException(status_code=500, detail='TURNSTILE_SECRET_KEY is missing')

    payload = {'secret': TURNSTILE_SECRET_KEY, 'response': token}
    if remote_ip:
        payload['remoteip'] = remote_ip

    async with httpx.AsyncClient(timeout=5) as client:
        response = await client.post(TURNSTILE_VERIFY_URL, data=payload)
        response.raise_for_status()
        result = response.json()

    if not result.get('success'):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail={'message': 'Invalid Turnstile token', 'error_codes': result.get('error-codes', [])},
        )

    return result

@app.post('/api/bot/turnstile-demo')
async def bot_turnstile_demo(payload: TurnstileBotRequest, request: Request):
    remote_ip = request.client.host if request.client else None
    await verify_turnstile_token(payload.turnstile_token, remote_ip)
    return {'accepted': True, 'message': 'The bot action can run now.'}

Environment variables

# Frontend, public
TURNSTILE_SITE_KEY=0x4AAAAAADL_SO30Hjut98cd

# Backend only, never commit the real value
TURNSTILE_SECRET_KEY=rotate_this_secret_in_cloudflare
TURNSTILE_EXPECTED_HOSTNAME=1938.com.es
INTERNAL_API_KEY=use_a_long_random_internal_key

4. Backend perimeter: Nginx, Cloudflare and alternatives

This part only works when the request really passes through your trusted server before reaching FastAPI. If Angular calls a separate API hostname directly, the Nginx rule on the website cannot inject any private header.

Why the previous Nginx idea may fail behind Cloudflare

Cloudflare sits before your origin. Nginx only sees traffic that Cloudflare forwards to that origin. Therefore, the safe pattern is: browser calls the same public site path, Cloudflare forwards it to your origin, and your origin proxy forwards only that API path to FastAPI while adding the internal header. If the browser calls api.example.com directly, that separate origin needs its own protection.

# This runs on your origin server, not in the browser.
# Angular calls: https://1938.com.es/api/bot/turnstile-demo
# Nginx forwards only this path to FastAPI and injects the internal header.

location = /api/bot/turnstile-demo {
    proxy_pass http://127.0.0.1:8000/api/bot/turnstile-demo;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # Keep this value in an nginx include owned by root, never in Angular.
    proxy_set_header X-Internal-Api-Key "change_this_internal_server_secret";
}

API privada con Nginx y FastAPI. Turnstile and the proxy solve different problems: Turnstile proves that a browser interaction passed Cloudflare validation; the proxy/internal header keeps private backend routes from being called directly.

Options compared

Turnstile only on FastAPI

Best for public forms and bot triggers. FastAPI is reachable, but every sensitive POST must validate the Turnstile token, rate limit and check hostname/action.

Nginx reverse proxy on the same VPS

Best when Angular SSR and FastAPI live on the same server or network. The browser calls /api/... and Nginx injects X-Internal-Api-Key before forwarding to localhost:8000.

Cloudflare Tunnel plus Access

Best when the API should not be public at all. Access can protect a self-hosted app and service tokens are suitable for server-to-server calls, not browser code.

Cloudflare WAF and rate limiting

Useful as an outer layer for abusive IPs, countries, methods or paths, but it does not replace Siteverify validation in FastAPI.

Cloudflare Worker as API gateway

Useful when you want Cloudflare to be the trusted gateway. The Worker can keep secrets in environment variables and forward only valid requests to the origin.

Cloudflare Worker gateway example

A Worker is useful when the request does not pass through your Nginx origin first. The browser calls the Worker route, the Worker keeps the internal key in Cloudflare environment variables and FastAPI still performs Turnstile validation.

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname !== '/api/bot/turnstile-demo' || request.method !== 'POST') {
      return new Response('Not found', { status: 404 });
    }

    const originRequest = new Request('https://api-origin.example.com/api/bot/turnstile-demo', {
      method: 'POST',
      headers: {
        'Content-Type': request.headers.get('Content-Type') ?? 'application/json',
        'X-Internal-Api-Key': env.INTERNAL_API_KEY,
        'CF-Connecting-IP': request.headers.get('CF-Connecting-IP') ?? '',
      },
      body: request.body,
    });

    return fetch(originRequest);
  },
};

Recommended setup for this tutorial

For a public bot form, keep Turnstile verification in FastAPI and expose only one POST route. If you also want the API private, make Angular call /api/bot/turnstile-demo on the same domain and let Nginx, a Worker or Cloudflare Access protect the origin. Never put the internal API key or Access service token in Angular.

5. Production checklist

1. Rotate secrets

If the secret key has been shared in a chat, ticket or commit, create a new one in Cloudflare and deploy it as TURNSTILE_SECRET_KEY.

2. Always validate server-side

The browser widget is not enough. FastAPI must call Siteverify before the bot runs.

3. Limit abuse

Keep slowapi, limit message length, avoid token logging and return generic errors.

4. Check the hostname

Configure TURNSTILE_EXPECTED_HOSTNAME to accept tokens from the intended domain.

5. Separate responsibilities

Turnstile does not replace login, permissions or the internal proxy key when the API should remain private.

6. Test the full flow

Verify valid tokens, expired tokens, missing tokens, rate limits and direct calls without the internal header.

Conclusion

Turnstile is not magic authentication, but it is a strong friction layer for forms and bot triggers when combined with server-side verification, rate limits and a private API perimeter.

After publishing, request indexing for both language versions because the page has a unique title, canonical, description, H1 and substantial SSR content.

Related readings

These articles complete the security and Angular SSR context: