When you allow users to post content or trigger an API call, you usually want to make sure that the action is actually submitted by a human being. Based on your needs, it's important to implement several strategies to mitigate risks. It's important to understand that a captcha is probably not the only strategy you want in place, but it is an important one. Let's see how to do that with SvelteKit.
hCaptcha
hCaptcha is an alternative to google's reCaptcha. It's privacy oriented, GPDR compliant and easy to use.
It has a free-tier that comes with some limitation but will be perfect for smaller traffic projects. If you have a commercial project that generates some income you might want to use their pro-tier in order to use their low-friction mode (users are not prompted to solve a puzzle every time).
Head over to hCaptcha in order to create an account. Once it's done you will need to create a new site in hCaptcha's interface.
Prerequisite
You'll need a SvelteKit project, of course. We will also use svelte-hcaptcha to make our task easier
npm install svelte-hcaptcha --save-dev
We also need to set-up our .env variables. Go to your hCaptcha account. On your dashboard, go to your site and get the Site Key. This is our public facing key. Now, go to the settings and grab your secret key (this is a private key that you don't want exposed ANYWHERE, we will use it to verify the captchas on the backend).
Now, at the root of your project, add to your .env (or create an .env file if there isn't one). Now add your secrets.
PRIVATE_CAPTCHA_KEY=
/*enter here your secret key*/
PUBLIC_SITEKEY=
/*enter here your site key*/
Before you do anything, please make sure that your .env is added to your .gitignore file. This will allow us to exclude it from our commits. During your deployments you will need to use the appropriate method to add your secrets to the service you use.
SvelteKit allows us to specify if a variable is public facing or private. variables starting with PRIVATE_ will only be accessible to the backend and won't be shared with the client, while PUBLIC_ will be available by the client.
Creating the form
Alright, now we're ready to roll. In our page or component, we need to create a form. Before submit we want to make sure the captcha has been solved Let's do that
<
script>
import
HCaptcha
from
'svelte-hcaptcha'
import {
PUBLIC_SITEKEY}
from
'$env/static/public'
const key =
PUBLIC_SITEKEY
</
script>
<
form
method=
"POST"
action=
"?/sendComment">
/*Here goes your form*/
</
form>
Let's take a closer look at what we've done so far.
- We imported the Hcaptcha component, as well as our SITE_KEY environment variable. We then create a "key" variable to which we assign the value of PUBLIC_SITEKEY. The only reason is to make it shorter to use in our code.
- We created a form element, added a POST method since we're sending data, and attributed a sendComment action to it
If you're wondering what this sendComment action is don't worry, we haven't implemented it yet.
Now, let's add our captcha component to our code inside our form
<
HCaptcha
sitekey=
{key}
bind:this=
{captcha}
on:success=
{handleSuccess}
on:error=
{handleError}
/>
At this point ESLint should give you several red squiggles. This is normal, some variables and functions haven't been created yet. Let's take a look at those parameters
- sitekey, as it's names indicates, is simply our hCaptcha sitekey. we are correctly passing that from our .env file
- bind:this allows us to bind our captcha to a variable so we can later reset it
- on:success and on:error allow us to trigger a function when the even is dispatched
If you want to learn more about svelte's events forwarding you can read about it here
Let's add all of that now
let captcha;
let token;
let isDisabled =
true
const
handleError = (
) => {
captcha.
reset();
}
const
handleSuccess = (
payload) => {
token = payload.
detail.
token
isDisabled =
false;
}
- we added a captcha variable
- we added a token variable. This will allow us to store the token sent by hCaptcha after it's been solved
- we also added a disabled boolean variable, it will be useful to handle the state of our button down the line
- we created a handleHerror function. It simply reset out captcha, but we could also log the error, send some feedback to the user etc. Feel free to add what you want there
- we also created a handleSuccess function that will take the response payload from hCaptcha and store the token. It will allow change disabled to false since our captcha has been solved
Adding the submit button
we need to add a button of type submit to our form. It will trigger the action we set-up in our form tag (that we haven't written yet)
<
button
type=
"submit"
disabled=
{isDisabled}>Send
</
button>
The submit button is disabled by default because it's value is the one of the variable "isDisabled". When the captcha is solved, it's value will change to "true" and the user will be able to submit the form
Getting our token ready to be passed to the server
When our form is submitted, we will beed to pass the token, returned by hCaptcha, to our backend in order to verify it. The easiest solution is to simply add a hidden input in our form
<
input
type=
"hidden"
name=
"token"
value=
{token}/>
When our captcha is solved it will trigger the handleSuccess method who will then store it. When the form is submitted we will then be able to easily pass our token to our backend.
So far this is how it should look :
<
script>
import
HCaptcha
from
'svelte-hcaptcha'
import {
PUBLIC_SITEKEY}
from
'$env/static/public'
const key =
PUBLIC_SITEKEY
let captcha;
let token;
let isDisabled =
true
const
handleError = (
) => {
captcha.
reset();
}
const
handleSuccess = (
payload) => {
token = payload.
detail.
token
isDisabled =
false;
}
</
script>
<
form
method=
"POST"
action=
"?/sendComment">
/*Here goes your form*/
<
HCaptcha
sitekey=
{key}
bind:this=
{captcha}
on:success=
{handleSuccess}
on:error=
{handleError}
/>
<
button
type=
"submit"
disabled=
{isDisabled}>Send
</
button>
</
form>
Great. So let's slow down a bit and try to understand what we've done so far.
We created a form. This form has a submit button, who is disabled by default. It also has a captcha. When the captcha is solved, the button is then enabled and the user can send the form.
Wonderful.
Wonderful, but not enough ! It would be trivial to change the state of this button and bypass our captcha. This is why you should ALWAYS, ALWAYS, use backend verification.
Fortunately for us, hCaptcha allow us to take our captcha token and our secret key and send it to them. They can then verify if the captcha was indeed solved and send us a response. If the user hasn't solved the captcha then we ignore the request. Rude but necessary. Let's do that
Creating the backend function
First we need to create the method that will handle the captcha verification. in our route, we need to create our +page.server.ts file. If you don't know what that is take a look at the documentation page
We're now in the +page.server.ts you have created. Let's set-up our imports :
import
type {
Actions}
from
'@sveltejs/kit';
import {
PRIVATE_CAPTCHA_KEY}
from
'$env/static/private'
import {
PUBLIC_SITEKEY}
from
'$env/static/public'
import {error}
from
"@sveltejs/kit";
const
verifyUrl:
string =
'https://hcaptcha.com/siteverify'
const
key:
string =
PRIVATE_CAPTCHA_KEY
const
siteKey:
string =
PUBLIC_SITEKEY
Since we're now in a TypeScript file, we import our Actions types from SvelteKit. We then import our secret key and our site key and assign them to shorter-named variables like we have before.In addition we import sveltekit's error handling library in order to throw errors
we also create a verifyUrl variables who will store the url that will be required in order to verify our token
Now let's create our action :
export
const
actions:
Actions = {
sendComment:
async({
request:
Request}) => {
}
}
That's the basis of our action. Now we want to get our data using formData(). In this example I will only be getting our token, but this is where you would get all of the value of the form inputs
inside my action :
const data =
await request.
formData();
const token = data.
get(
'token');
Alright. Now that our backend has the captcha token we can ask hCaptcha's server to verify it.
if(!token) {
throw
error(
403, {
message:
'No token'
})
}
if no token was provided we will just throw and nothing else will happen.
const body =
new
URLSearchParams({
secret: key,
response: token
as
string,
sitekey: siteKey
})
We now prepare our body to be sent. We pass our secret key, the response token as well as the sitekey
Everything is ready, let's make the request.
const response =
await
fetch(verifyUrl, {
method:
'POST',
credentials:
'omit',
headers: {
'Content-Type':
'application/x-www-form-urlencoded',
},
body,
});
And now let's get the success status from the response :
const {success} =
await response.
json();
Now we know if the token has been verified or not
if (success) {
// do your thing...
}
else {
throw
error(
403, {
message:
'access denied'
})
}
That should look like this :
import
type {
Actions}
from
'@sveltejs/kit';
import {
PRIVATE_CAPTCHA_KEY}
from
'$env/static/private'
import {
PUBLIC_SITEKEY}
from
'$env/static/public'
import {error}
from
"@sveltejs/kit";
const
verifyUrl:
string =
'https://hcaptcha.com/siteverify'
const
key:
string =
PRIVATE_CAPTCHA_KEY
const
siteKey:
string =
PUBLIC_SITEKEY
export
const
actions:
Actions = {
sendComment:
async ({
request:
Request}) => {
const data =
await request.
formData();
const token = data.
get(
'token');
if (!token) {
throw
error(
403, {
message:
'No token'
})
}
const body =
new
URLSearchParams({
secret: key,
response: token
as
string,
sitekey: siteKey
})
const response =
await
fetch(verifyUrl, {
method:
'POST',
credentials:
'omit',
headers: {
'Content-Type':
'application/x-www-form-urlencoded',
}, body,
});
const {success} =
await response.
json();
if (success) {
// do your thing...
}
else {
throw
error(
403, {
message:
'access denied'
})
}
}}
Neat ! Now there is one more thing we can do on the frontend and that's progressive enhancement. It will allow us to use javascript if allowed in order to get a result without reloading for example. If you;re not familiar with it I would suggest reading the documentation about progressive enhancement
In order to do that we need to modify our form opening tag and add use:enhance to it
<form method=
"post" action=
"?/sendComment"
use:enhance={
(
{form}) => {
return
async ({ result }) => {
if (result.
type ===
'success') {
console.
info(
"success")
isDisabled =
true;
captcha.
reset();
form.
reset();
}
else {
console.
error(
"error");
isDisabled =
true;
captcha.
reset();
} }
}}>
This is pretty straightforward. After the action has been triggered, the frontend will get in return a result. We can then let our frontend leverage that. If the result is 'success' we will reset the form and the captcha and disable the button. We could have an alert letting us know the form was submitted. On the other hand if the result is 'error' we reset the captcha and disable the button. This is also where we would let our user know that something went wrong.
This conclude this tutorial. If you have any question leave a comment and I'll try to help.