DEV Community

webdecoy
webdecoy

Posted on

Your Angular form has validation. Bots don’t care.

A disabled submit button is useful feedback. It does not protect the endpoint behind it.

Someone can skip your Angular application and send an HTTP request directly. Validators.required never runs in that request. Neither does your submit handler.

Let's build a small contact form where the backend decides whether the request is accepted. It uses Angular reactive forms, Express and self-hosted FCaptcha.

Disclosure: I build WebDecoy and maintain FCaptcha. This is a runnable integration tutorial for our open-source project.

Run the example

Complete example and README. This tutorial's source is on the docs/angular-nextjs-tutorials branch; it does not depend on a future merge to main.

Use a current Node version compatible with Angular 22 (tested with Node 26.5). The example pins Angular 22.2 and uses FCaptcha 1.42.

git clone --branch docs/angular-nextjs-tutorials https://github.com/WebDecoy/FCaptcha.git
cd FCaptcha
npm --prefix server-node install --ignore-scripts --package-lock=false
cd examples/framework-forms
npm ci
npm --prefix angular ci
npm run angular
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:4200, not localhost. The allowed hostname is intentionally exact. The launcher starts Angular, an Express API on port 4201 and FCaptcha on port 8788. Angular proxies /api to Express so the form submits to its own origin.

The server dependency command works around an existing server-node lockfile mismatch. The example itself includes dependency lockfiles.

Keep Angular validation for the user experience

The standalone component imports ReactiveFormsModule and uses a typed form control:

form = new FormGroup({
  message: new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.maxLength(2000)]
  })
});
busy = signal(false);
status = signal('');
Enter fullscreen mode Exit fullscreen mode

The template binds the form and displays a live status message:

<form [formGroup]="form" (ngSubmit)="submit()">
  <label for="message">Test message</label>
  <textarea id="message" formControlName="message"
            maxlength="2000" required></textarea>
  <button type="submit" [disabled]="form.invalid || busy()">
    {{ busy() ? 'Verifying…' : 'Verify and submit' }}
  </button>
</form>
<p role="status" aria-live="polite">{{ status() }}</p>
Enter fullscreen mode Exit fullscreen mode

The submit method checks validity, sets busy before awaiting anything, and clears it in finally. That prevents accidental double clicks and leaves the form usable after errors. The full component is in angular/src/main.ts.

These controls improve the browser experience. The server repeats the checks because it cannot trust the browser.

Get a token, then submit it to your API

The shared browser helper lazily loads /fcaptcha.js from the local FCaptcha server and configures that server URL. After loading, the important sequence is:

const result = await window.FCaptcha.execute('framework-contact', {
  action: 'contact', lang: 'en'
});
if (!result.success || !result.token) {
  throw new Error('Verification did not pass. Please try again.');
}
const response = await fetch('/api/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, token: result.token }),
  signal: AbortSignal.timeout(8000)
});
Enter fullscreen mode Exit fullscreen mode

The helper checks the API response too. A successful client result alone never means the message was accepted. Each attempt gets a fresh token; a successfully verified token cannot be reused.

Express makes the decision

angular/api.mjs limits the request body to 16 KiB and passes a Web Request to shared/contact.mjs. The shared handler validates JSON, limits messages to 2,000 characters and requires a token before contacting FCaptcha.

This is the core server-side verification step, excerpted from that handler:

const verification = await fetch(new URL('/siteverify', captchaOrigin), {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ secret: verifySecret, response: body.token }),
  signal: AbortSignal.timeout(5000),
  cache: 'no-store'
});
if (!verification.ok) throw new Error('Verification unavailable');
const result = await verification.json();

if (result?.success !== true ||
    result.hostname !== hostname ||
    result.action !== 'contact') {
  return Response.json({ error: 'captcha_rejected' }, { status: 403 });
}
// Only now perform the protected action.
Enter fullscreen mode Exit fullscreen mode

verifySecret exists only in the server process. The public site key is not a secret. The application checks both the expected hostname and the contact action, rather than accepting any successful token from any workflow.

Network errors, non-2xx verification responses and malformed verification JSON produce a 503 response. Rejected tokens produce 403. Neither path sends a message.

What this example tests

npm test runs 17 integration checks across the Express adapter and the Next.js route handler against the real FCaptcha verification endpoint. They cover valid tokens, replay, forged and expired tokens, hostname/action mismatches, input limits, origin checks and verification outages. Both framework production builds also passed.

The tests sign fixtures with a temporary test key. They test the integration contract—not the browser's ability to distinguish humans from bots. No human pass-rate claim follows from these tests.

Before you deploy

The launcher is deliberately local: it creates fresh, separate signing and verification secrets for every run and binds FCaptcha to loopback. The browser helper also uses a hardcoded loopback URL. Replace those with your own HTTPS origins, public site key and stable server-side secrets for deployment.

Keep rate limits, request-size limits and authentication/authorization where needed. An Origin check helps with unwanted browser requests, but a script can forge that header. CAPTCHA is another check, not a replacement for authorization or a complete spam defense.

When you add email or database writes, handle duplicate business actions separately. Verification consumes the token; if the later action fails, the retry needs a new token. Multiple FCaptcha replicas also need shared state for replay protection. Give legitimate visitors a recovery path if verification fails.

This demo only returns { accepted: true, demoOnly: true }. It does not store a message or send an email.

FCaptcha is free and open source. If you try the example, useful feedback is a reproducible integration issue—including framework version and the failing step—with tokens and secrets removed.

Framework reference: Angular reactive forms.

Top comments (0)