Add reCAPTCHA to a Svelte App

Any unprotected forms on the public internet are susceptible to spam attacks by malicious bots. Solutions such as reCAPTCHA have emerged which mitigate such attacks by presenting suspected bots with a challenge to complete. This tutorial will walk through adding reCAPTCHA to a Svelte application.

Follow along in the repository on GitHub and consider sponsoring my work for more articles ❤️

GitHub - MagsMagnoli/svelte-recaptcha-tutorial: A tutorial for adding recaptcha to a svelte app
A tutorial for adding recaptcha to a svelte app. Contribute to MagsMagnoli/svelte-recaptcha-tutorial development by creating an account on GitHub.

Setting up reCAPTCHA

Head over to the reCAPTCHA admin console to register your website and generate your site key and secret key.

For this tutorial we're going to be selecting the reCAPTCHA v2 invisible reCAPTCHA badge. reCAPTCHA v3 doesn't present users with a challenge and is best used for complex application flows. The invisible badge fits our simple requirements and is less intrusive than the "I'm not a robot" checkbox.

After completing the setup form you should be presented with your reCAPTCHA keys. The site key will be used on the frontend and the secret key will be used on the backend.

Do not, under any circumstance, commit the secret key to source control or expose it in any way outside of your application environment!

Limiting exposure of the site key is a good habit but since it will be visible on the frontend it isn't as important as securing the secret key.

Application Overview

Now that we have everything we need to setup reCAPTCHA it's time to code!

The demo application we're going to be working with is a simple login form that records how many requests are sent to the server. We can see that each click of the login button sends a request to the backend.

In it's current state this is a prime target for abuse 😓 but we're going to fix that with reCAPTCHA 😁

Implementing reCAPTCHA on the frontend

The first thing we need to do is to add the reCAPTCHA library dependency to our project. We'll do this by incorporating a script tag into the head of our html.

<script src="https://www.google.com/recaptcha/api.js" async defer></script>

Note that we are using async and defer to prevent parser-blocking JavaScript.

With the library in place let's now add the following div element to our form:

<div
    class="g-recaptcha"
    data-sitekey={RECAPTCHA_SITE_KEY}
    data-callback="handleCaptchaCallback"
    data-expired-callback="resetCaptcha"
    data-error-callback="handleCaptchaError"
    data-size="invisible"
/>

This element employs a number of data attributes which are defined by the the reCAPTCHA library. The full list of available options are described in detail here so for brevity:

  • data-sitekey - the site key we obtained earlier
  • data-callback - a function to call after a successful reCAPTCHA invocation
  • data-expired - a function to call when the reCAPTCHA session has expired
  • data-error - a function to call after a failed reCAPTCHA invocation
  • data-size - instructs reCAPTCHA we'll be using an invisible widget

Setting the site key can be as simple as hard coding it on the element but since we're here to learn let's introduce it as an environment variable 😄

Environment variables in SvelteKit

SvelteKit supports environment variables natively if we do the following:

  • create a .env file at the root of our project
  • prefix any environment variable we want to use on the frontend with VITE_
  • for typescript support, add the variable to ImportMetaEnv type definition
  • access the variable in code

It's important to note that any environment variable we want to use on the server side only, such as the secret key, should NOT be prefixed with VITE_ and need to be loaded with a library such as dotenv.

Also remember to NEVER commit secret environment variables to version control!

First we create a .env file at the root of our project

VITE_RECAPTCHA_SITE_KEY=YOUR_SITE_KEY
RECAPTCHA_SECRET_KEY=YOUR_SECRET_KEY

Then we update our typescript definitions in lib/types.d.ts:

interface ImportMetaEnv {
  VITE_RECAPTCHA_SITE_KEY: string;
}

And finally we can access the variable we set on the div element in index.svelte:

const RECAPTCHA_SITE_KEY = import.meta.env.VITE_RECAPTCHA_SITE_KEY;

ReCAPTCHA callbacks

There are three callback functions we defined on the reCAPTCHA element that we need to implement. Unfortunately it's not as easy as assigning each to a function we define in Svelte. reCAPTCHA looks for these functions on the window object so we need to assign our functions to it first. To enable Typescript support we must add the following type definition to our lib/types.d.ts file:

export declare global {
  interface Window {
    handleCaptchaCallback: (token: string) => Promise<void>;
    resetCaptcha: () => void;
    handleCaptchaError: () => void;
  }
}

Then in the onMount of index.svelte we make the assignments:

onMount(() => {
    window.handleCaptchaCallback = handleCaptchaCallback;
    window.handleCaptchaError = handleCaptchaError;
    window.resetCaptcha = resetCaptcha;
});

We'll implement handleCaptchaError as a function that displays an error string to the user:

const handleCaptchaError = () => {
    error = 'Recaptcha error. Please reload the page';
};

We then add a simple error message inside our form. Be sure to initialize error as an empty string at the top of index.svelte:

{#if error}
    <div>
        <small class="text-yellow-300 font-bold">{error}</small>
    </div>
{/if}

If the user doesn't fill out the form after a period of time their current session will expire. Instead of displaying an error when they submit the form after this point, we'll implement resetCaptcha to automatically refresh our session thereby keeping the form always in a stable state.

const resetCaptcha = () => {
    window.grecaptcha.reset();
};

grecaptcha is a global variable created by the reCAPTCHA library. Install the grecaptcha package on npm to make it available in Typescript.

The final callback to implement is handleCaptchaCallback which is executed after reCAPTCHA completes successfully. It is called with a token that we will need to send to our backend. We'll move the networking logic from our handleSubmit function into this callback and replace it with logic that calls out to reCAPTCHA to process.

const handleCaptchaCallback = async (token: string) => {
    await fetch('/api/login', {
        method: 'POST',
        headers: {
        'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            username,
            password,
            recaptchaToken: token
        })
    });

    requests += 1;
    
    // reset recaptcha for future requests
    resetCaptcha();
};

const handleSubmit = () => {
    // reset any errors
    error = '';
    
    // tell recaptcha to process a request
    window.grecaptcha.execute();
};

Implementing reCAPTCHA on the backend

We're beginning the final stretch! The server is responsible for verifying the authenticity of the reCAPTCHA token. In order to do that we need to send a POST request to a special endpoint. This request requires our RECAPTCHA_SECRET_KEY environment variable in our .env file. To initialize environment variables on the server, first install dotenv then import it at the top of login.ts. Now our variables will be accessible on the process.env:

import dotenv from 'dotenv';

dotenv.config();

const { RECAPTCHA_SECRET_KEY } = process.env;

Although not required, reCAPTCHA accepts an ip address in the verification request. This value is available to us as the SvelteKit RequestHandler object's host property.

With these three values in hand, we can now make a validation request, then either send the user a failure message or continue forward in our server processing:

const recaptchaVerifyResponse = await fetch(
    `https://www.google.com/recaptcha/api/siteverify?   secret=${RECAPTCHA_SECRET_KEY}&response=${recaptchaToken}&remoteip=${host}`,
    {
        method: 'POST'
    }
);

const json: {
    success: boolean;
} = await recaptchaVerifyResponse.json();

if (!json.success) {
    return {
        status: 400,
        body: 'ReCAPTCHA failed. Please try again'
    };
}

On the frontend we modified our request to process the response and display the error string if present. We also made a user class for convenience in types.d.ts

// index.svelte

const json: User | string = await response.json();

if (response.status === 200) {
    // do something with response
} else {
    // show error message
    error = json as string;
}

// types.d.ts

export type User = {
    id: number;
    username: string;
};

We successfully secured our login form with reCAPTCHA! 🎉  

Wrapping Up

Thanks for reading! If you enjoyed this article, please consider sharing it with others and becoming a sponsor on GitHub. ❤️