recaptcha-angular

July 24, 2026 · View on GitHub

Lightweight, zero-dependency Angular component for Google reCAPTCHA v2 (checkbox) and v3 (score-based) with full TypeScript support, standalone APIs, and ControlValueAccessor (ngModel / reactive forms) integration. Switch between v2 and v3 with a single version input.

npm license CI

Coverage (generated locally with npm run test:coverage, no external service):

StatementsBranchesFunctionsLines
StatementsBranchesFunctionsLines

This is the Angular port of recaptcha-vue. Same behaviour, Angular idioms.


Table of contents


Features

  • v2 and v3 in one component: version="v2" (default) or version="v3"
  • Standalone component (Angular 17+), no NgModule required
  • TypeScript: full types for inputs, outputs, and the public methods
  • RecaptchaService: signal-based token & isVerified state
  • Forms: implements ControlValueAccessor, works with [(ngModel)] and formControlName
  • Multiple instances: safe to use more than one widget per page
  • Theming: light / dark, normal / compact
  • Language: pass any BCP 47 code (hl param)
  • Load timeout: emits error if the script never loads
  • Ivy partial-compilation build via ng-packagr, zero runtime dependencies

Requirements

Version
Node.js>=20.19.0 (see .nvmrc)
Angular>=17.0.0 (peer dependency)

@angular/core, @angular/common, and @angular/forms are peer dependencies.


Installation

npm install recaptcha-angular

Quick start

1. Get your reCAPTCHA v2 keys

Register at https://www.google.com/recaptcha/admin. Choose reCAPTCHA v2 -> "I'm not a robot" Checkbox.

Test keys (always pass, never use in production): Site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI Secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe

2. Use the component

import { Component } from '@angular/core'
import { RecaptchaComponent, RecaptchaService } from 'recaptcha-angular'

@Component({
  selector: 'app-form',
  standalone: true,
  imports: [RecaptchaComponent],
  providers: [RecaptchaService],
  template: `
    <recaptcha-v2
      sitekey="YOUR_SITE_KEY"
      (verify)="captcha.onVerify($event)"
      (expire)="captcha.onExpire()"
      (error)="captcha.onError()"
    ></recaptcha-v2>
    <button [disabled]="!captcha.isVerified()">Submit</button>
  `,
})
export class FormComponent {
  constructor(public captcha: RecaptchaService) {}
}

Token expiry and resetting

Important

Read this before wiring up a form. The most common integration bug with any reCAPTCHA v2 wrapper is a form that works once in testing, then silently submits a stale or already-used token in production. (Separately: your server must verify the token regardless of expiry, see Client state is not verification.)

A verified token is only valid for about 2 minutes (Google's own limit), and it's single-use: once you've submitted it to your backend, that exact token cannot be verified again, whether verification succeeded or failed. Two failure modes follow directly from that:

  1. The user waits too long. The checkbox stays visually "checked," but the token behind it has expired. Handle this with the (expire) output (wired to RecaptchaService.onExpire), which flips isVerified() back to false so your submit button disables itself again instead of sending a dead token.
  2. The user submits, something else fails, they retry. Say the token verifies fine but a different field fails server-side validation. If you don't reset the widget, the user fixes that field and resubmits the same token, which your backend now rejects, and it looks like reCAPTCHA itself is broken.

The fix for both is the same one-liner, and it belongs in every code path that leaves the form, success or failure. Grab the component with a template ref and call reset():

<recaptcha-v2 #captcha sitekey="..."></recaptcha-v2>
@ViewChild('captcha') captcha!: RecaptchaComponent
// ...
this.captcha.reset()

Inputs

InputTypeDefaultDescription
sitekeystringrequiredYour reCAPTCHA site key
version'v2' | 'v3''v2'Which reCAPTCHA to use. See reCAPTCHA v3
actionstring'submit'v3 only. Default action when execute() is called with no argument
theme'light' | 'dark''light'v2 only. Widget color scheme
size'normal' | 'compact''normal'v2 only. Widget size
tabindexnumber0v2 only. Tab index
loadingTimeoutnumber30000ms before error fires if the script never loads
languagestring''BCP 47 language code, e.g. 'fr', 'ar'
badge'bottomright' | 'bottomleft' | 'inline''bottomright'v2 only. Badge position (invisible size only)
hideBadgebooleanfalsev3 only. Hide the floating badge (see the legal note in reCAPTCHA v3)
isolatedbooleanfalsev2 only. Isolate widget from others on the page

Outputs

OutputPayloadFires when
verifystringUser completed the challenge; token ready to send to server
expirevoidToken expired; user must re-verify
errorvoidWidget or network error, or load timeout
widgetIdnumberWidget rendered (internal widget id)

"Ready to send to server" is doing a lot of work in that first row. See Client state is not verification for why sending it isn't the same as being verified.


Public methods (via template ref)

<recaptcha-v2 #captcha sitekey="..."></recaptcha-v2>
<button (click)="captcha.reset()">Retry</button>
  • reset() resets the widget (v2) or clears the token (v3)
  • execute(action?) returns Promise<string>. On v3 it runs the challenge for the action and resolves with the token; on v2 it triggers the challenge and resolves when the next verify fires
  • getResponse() returns the current token string

reCAPTCHA v3

reCAPTCHA v3 is score-based and renders no widget: there is nothing to click. Set version="v3" and get a token on demand by calling execute(action), usually right before you submit. (verify) still fires with the token, so RecaptchaService works exactly as it does for v2.

import { Component, ViewChild } from '@angular/core'
import { RecaptchaComponent } from 'recaptcha-angular'

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [RecaptchaComponent],
  template: `
    <form (ngSubmit)="submit()">
      <!-- No visible widget on v3, just the floating badge -->
      <recaptcha-v2 #captcha sitekey="YOUR_V3_SITE_KEY" version="v3"></recaptcha-v2>
      <button type="submit">Log in</button>
    </form>
  `,
})
export class LoginComponent {
  @ViewChild('captcha') captcha!: RecaptchaComponent

  async submit() {
    const token = await this.captcha.execute('login')
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ 'g-recaptcha-response': token }),
    })
  }
}

Notes specific to v3:

  • Get a fresh token per submit. v3 tokens are single-use and expire in about 2 minutes, so call execute() at submit time, not on init.
  • The badge and the law. v3 shows a floating "protected by reCAPTCHA" badge. You may hide it with hideBadge, but only if you then display the required legal text yourself.
  • Server-side gives you a score. siteverify returns score (0.0 to 1.0) and action. Reject low scores and confirm the action matches.
  • One version per page. Rendering a v2 and a v3 instance on the same page is not supported (they share Google's single grecaptcha global). Pick one.

Forms integration

The verified token is the control value, so the component drops straight into template-driven or reactive forms:

<!-- template-driven -->
<recaptcha-v2 sitekey="..." [(ngModel)]="token" name="captcha"></recaptcha-v2>

<!-- reactive -->
<recaptcha-v2 sitekey="..." formControlName="captcha"></recaptcha-v2>

Setting the bound value to '' resets the widget. Any other value is owned by grecaptcha and cannot be written programmatically, so it's ignored.


RecaptchaService

Signal-based state, mirroring the Vue composable / React hook. Provide it at the component level (not root) so each form gets its own instance.

captcha.token()       // Signal<string>: current token ('' when expired / error)
captcha.isVerified()  // computed Signal<boolean> (client-side only, see below)
captcha.onVerify(t)   // wire to (verify)
captcha.onExpire()    // wire to (expire)
captcha.onError()     // wire to (error)
captcha.reset()       // clear the tracked state (call component.reset() too)

Client state is not verification

Warning

isVerified() and token() are client-side state only. They exist to drive UX, e.g. disabling the submit button until the checkbox is solved, and they are never proof that verification actually happened. Any client can set them to whatever it wants before the request reaches your server.

Your server must independently POST the token to https://www.google.com/recaptcha/api/siteverify with your secret key, check the success field, and reject the request when it's false. See Server-side verification below.

Tokens are also single-use and expire after about 2 minutes (see Token expiry and resetting). A reused or expired token comes back from siteverify as success: false with error-codes: ["timeout-or-duplicate"].


Server-side verification

Always verify the token on your backend against https://www.google.com/recaptcha/api/siteverify with your secret key. See examples/contact.component.ts for a full form, and the recaptcha-vue repo for a ready-made Laravel controller and validation rule.


Multiple instances on one page

Each <recaptcha-v2> instance manages its own unique widget id and global callback names, so you can safely render more than one widget:

<recaptcha-v2 [sitekey]="siteKey" (verify)="onLogin($event)"></recaptcha-v2>
<recaptcha-v2 [sitekey]="siteKey" (verify)="onSignup($event)"></recaptcha-v2>

Local development

git clone https://github.com/Souhailmakni/recaptcha-angular.git
cd recaptcha-angular
npm install

Scripts

CommandDescription
npm run buildBuild the library into dist/recaptcha-angular with ng-packagr
npm testRun the Karma/Jasmine specs headless once
npm run test:watchRun the specs in watch mode
npm run test:coverageRun the specs with coverage and update the README badges
npm run packBuild, then npm pack the publishable tarball for inspection

Publishing is done from the built output:

npm run build && cd dist/recaptcha-angular && npm publish --access public

License

MIT © Souhail Makni