Cloudflare Turnstile · Angular SSR · FastAPI

Cloudflare Turnstile con Angular y FastAPI para proteger la interacción con un bot

Turnstile es útil cuando una página Angular pública debe enviar un mensaje, lanzar un flujo de bot o llamar a una acción sensible del backend sin quedar abierta al abuso automatizado.

La arquitectura segura es sencilla: Angular pinta el widget y envía el token temporal; FastAPI valida ese token con Cloudflare antes de que el bot haga nada.

Regla de seguridad

La secret key nunca debe estar en Angular, Git, capturas ni tutoriales. Si una secret se ha compartido, hay que rotarla en Cloudflare y desplegar el nuevo valor solo como variable de entorno del backend.

Este artículo forma parte del hub de seguridad y captcha.

1. Arquitectura

El navegador no es confiable. Solo puede obtener un token temporal de Turnstile. FastAPI valida ese token en servidor, aplica rate limits y despues ejecuta la accion del bot.

1

Frontend Angular

Site key publica usada en el ejemplo Angular: 0x4AAAAAADL_SO30Hjut98cd

2

Backend FastAPI

Recibe turnstile_token, valida longitud, rate limit y cabecera interna.

3

Cloudflare Siteverify

Confirma que el token es autentico, no esta caducado y no se ha reutilizado.

4

Accion protegida del bot

Solo se ejecuta cuando FastAPI ha recibido una respuesta success.

2. Integracion en Angular

En Angular SSR, carga el script de Turnstile solo en navegador. El widget devuelve un token mediante callback; ese token se envia junto con el formulario.

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;
  }
}

Componente del formulario

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);
  }
}

Plantilla HTML

<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. Validacion en FastAPI

El backend envia el token a Cloudflare Siteverify. Solo si la respuesta es correcta, el endpoint protegido devuelve datos utiles o ejecuta el bot.

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.'}

Variables de entorno

# 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. Perimetro del backend: Nginx, Cloudflare y alternativas

Esta parte solo funciona cuando la peticion pasa realmente por tu servidor de confianza antes de llegar a FastAPI. Si Angular llama directamente a otro hostname/API, la regla de Nginx de la web no puede inyectar ninguna cabecera privada.

Por que la idea anterior de Nginx puede fallar detras de Cloudflare

Cloudflare esta delante del origen. Nginx solo ve el trafico que Cloudflare reenvia a ese origen. Por eso el patron seguro es: el navegador llama a una ruta del mismo sitio publico, Cloudflare la reenvia al origen, y el proxy del origen reenvia solo esa ruta API a FastAPI añadiendo la cabecera interna. Si el navegador llama directamente a api.example.com, ese origen separado necesita su propia proteccion.

# 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 y el proxy resuelven problemas distintos: Turnstile demuestra que una interaccion del navegador paso la validacion de Cloudflare; el proxy/cabecera interna evita que rutas privadas del backend se llamen directamente.

Comparacion de opciones

Solo Turnstile en FastAPI

Mejor para formularios publicos y disparadores de bot. FastAPI es alcanzable, pero cada POST sensible debe validar el token Turnstile, aplicar rate limit y revisar hostname/action.

Nginx reverse proxy en el mismo VPS

Mejor cuando Angular SSR y FastAPI viven en el mismo servidor o red. El navegador llama a /api/... y Nginx inyecta X-Internal-Api-Key antes de reenviar a localhost:8000.

Cloudflare Tunnel mas Access

Mejor cuando la API no debe ser publica. Access puede proteger una aplicacion self-hosted y los service tokens sirven para servidor-servidor, no para codigo de navegador.

Cloudflare WAF y rate limiting

Util como capa exterior para IPs, paises, metodos o rutas abusivas, pero no sustituye la validacion Siteverify dentro de FastAPI.

Cloudflare Worker como API gateway

Util cuando quieres que Cloudflare sea la pasarela confiable. El Worker puede guardar secretos en variables de entorno y reenviar solo peticiones validas al origen.

Ejemplo con Cloudflare Worker como gateway

Un Worker es util cuando la peticion no pasa primero por tu Nginx de origen. El navegador llama a la ruta del Worker, el Worker guarda la clave interna en variables de entorno de Cloudflare y FastAPI sigue validando Turnstile.

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);
  },
};

Configuracion recomendada para este tutorial

Para un formulario publico de bot, deja la verificacion Turnstile en FastAPI y expón solo una ruta POST. Si ademas quieres que la API sea privada, haz que Angular llame a /api/bot/turnstile-demo en el mismo dominio y deja que Nginx, un Worker o Cloudflare Access protejan el origen. Nunca pongas la clave interna ni un service token de Access en Angular.

5. Checklist de produccion

1. Rotar secretos

Si la secret key se ha compartido en un chat, ticket o commit, crea una nueva en Cloudflare y despliegala como TURNSTILE_SECRET_KEY.

2. Validar siempre en servidor

El widget del navegador no protege por si solo. FastAPI debe llamar a Siteverify antes de ejecutar el bot.

3. Limitar abuso

Manten slowapi, limita la longitud del mensaje, no registres tokens y devuelve errores genericos.

4. Revisar hostname

Configura TURNSTILE_EXPECTED_HOSTNAME para aceptar tokens del dominio previsto.

5. Separar responsabilidades

Turnstile no sustituye login, permisos ni la clave interna del proxy cuando la API no debe ser publica.

6. Probar el flujo completo

Verifica token valido, token caducado, ausencia de token, rate limit y llamada directa sin cabecera interna.

Conclusion

Turnstile no es autenticacion magica, pero si una capa fuerte contra abuso en formularios y disparadores de bots cuando se combina con verificacion server-side, rate limits y API privada.

Despues de publicar, pide indexacion de las dos versiones porque la pagina tiene title, canonical, description, H1 y contenido SSR propio.

Lecturas relacionadas

Estos articulos completan el contexto de seguridad y Angular SSR: