Blog
Dec 3, 2025
Implementing a tracking-free captcha with Altcha and Nuxt

Implementing a tracking-free captcha with Altcha and Nuxt

Hugo

For the past few days, I've noticed several suspicious uses of my contact form.

Looking closer, I noticed that each contact form submission was followed by a user signup with the same email and a name that always followed the same pattern:

qSfDMiWAiLnpYYzdCeCWd

fePXzKXbAmiLAweNZ

etc... Let's just say their membership in the human species seems particularly dubious.

Anyway, it's probably time to add some controls, and one of the most famous is the captcha.

Next-generation captchas

Everyone knows captchas – they're annoying, probably on par with cookie consent banners.

Nowadays we see captchas where you have to identify traffic lights, solve additions, drag a puzzle piece to the right spot, and so on.

But you may have noticed that lately we're also seeing simple forms with a checkbox: "I am not a robot".

I'm not a robot
I'm not a robot

Sometimes the captcha isn't even visible anymore, with detection happening without asking you anything.

So how does it work? And how can I add it to my application?

Nuxt Turnstile, the default solution with Nuxt

In the Nuxt ecosystem, the most common solution is Nuxt turnstile. The documentation is pretty clear on how to add it.

It's a great solution, but it relies on Cloudflare turnstile, and I'm trying to use only european products for Writizzy and Hakanai.

Still, the documentation helps understand a bit better how next-generation captchas work.

When the page loads, the turnstile widget performs client-side checks:

  • **proof of space: **The script asks the client to generate and store an amount of data according to a predefined algorithm, then asks for the byte at a given position. Not only does this take time, but it's difficult to automate at scale.
  • trivial browser detections: The idea is to try to detect a bot (no plugins, webdriver control, etc.). Fingerprinting also helps in this case. It collects all available info about the browser, OS, available APIs, resolution, etc.

Note that fingerprinting can be frowned upon by GDPR, which may consider it as uniquely identifying a person. Personally, I find that debatable, but in the context of anti-spam protection, we're kind of chasing our tail here since it would be necessary to ask bots for their permission to try to detect them. We're at the limits of absurdity here.

But let's continue. Based on the previous info, the script sends all this to Cloudflare. Based on this info and relying on a huge database of worldwide traffic, Cloudflare calculates a percentage chance that the user is a bot.

The form will vary between:

  • nothing to do, Cloudflare is convinced it's a human
  • a checkbox "I am not a robot"
  • a more elaborate captcha if the suspicion is really strong
  • a blocking page when there's no doubt about the suspicion

Now, you might say, the checkbox is a bit light, isn't it? If I've gotten this far, I can easily automate a click on a checkbox. Especially since Cloudflare is everywhere, it's necessarily the same form everywhere.

Yes... But...

First, the way you check the box will be analyzed. Is the click too fast, does it seem automated, is the mouse path to reach the box natural? All this can trigger additional protection.

EDIT: Turnstile might not do this operation. reCaptcha, Google's solution, is known for doing it. Turnstile is less explicit on the subject.

But on top of that, the checkbox triggers a challenge, a small calculation requested by Cloudflare that your client must perform. The result is what we call a proof of work.

This work is slow for a computer. We're talking about 500ms, an eternity for a machine. For a human user, it's totally anecdotal. And the satisfaction of having proven their humanity makes you forget those 500 little milliseconds.

On the other hand, for a bot, this time will be a real problem if it needs to automate the creation of hundreds or thousands of accounts.

So it's not impossible to check this box, but it's costly. And it's supposed to make the economic equation uninteresting at high volumes.

Now, even though all this is nice, I still don't want to use Cloudflare, so how do I replace it?

Altcha, an open-source alternative

During my research, I came across altcha. The solution is open source, requires no calls to external servers, and shares no data.

The implementation requires requesting the Proof of Work (the famous JavaScript challenge) from your server. Here we'll initiate it from the Nuxt backend, in a handler:

typescript

typescript
// server/api/altcha/challenge.get.ts
import { createChallenge } from 'altcha-lib'

export default defineEventHandler(async () => {
  const hmacKey = useRuntimeConfig().altchaHmacKey as string

  return createChallenge({
    hmacKey,
    maxnumber: 100000,
    expires: new Date(Date.now() + 60000) // 1 minute
  })
})

In the contact form page, we'll add a Vue component:

vue

vue
    <ClientOnly>
      <Altcha
        v-model:payload="altchaPayload"
      />
    </ClientOnly>

This altchaPayload will be added to the post payload, for example:

typescript

typescript
    await $fetch('/api/contact', {
      method: 'POST',
      body: {
        email: loggedIn.value ? user.value?.email : event.data.email,
        subject: event.data.subject,
        message: event.data.message,
        altcha: altchaPayload.value
      }
    })

The calculation result will then be verified in the /api/contact endpoint

typescript

typescript
    const hmacKey = useRuntimeConfig().altchaHmacKey as string

    const ok = await verifySolution(data.altcha, hmacKey)
    if (!ok) {
      throw createError({ statusCode: 400, message: 'Invalid challenge' })
    }

The Vue component I mentioned earlier is this one:

vue

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'

const altchaWidget = ref<HTMLElement | null>(null)
const props = defineProps({
  payload: {
    type: String,
    required: false
  }
})
const emit = defineEmits<{
  (e: 'update:payload', value: string): void
}>()
const internalValue = ref(props.payload)

watch(internalValue, (v) => {
  emit('update:payload', v || '')
})

const onStateChange = (ev: CustomEvent | Event) => {
  if ('detail' in ev) {
    const { payload, state } = ev.detail
    if (state === 'verified' && payload) {
      internalValue.value = payload
    } else {
      internalValue.value = ''
    }
  }
}

onMounted(() => {
  const script = document.createElement('script')
  script.src = 'https://cdn.jsdelivr.net/gh/altcha-org/altcha@main/dist/altcha.min.js'
  script.type = 'module'
  document.head.appendChild(script)

  if (altchaWidget.value) {
    altchaWidget.value.addEventListener('statechange', onStateChange)
  }
})

onUnmounted(() => {
  if (altchaWidget.value) {
    altchaWidget.value.removeEventListener('statechange', onStateChange)
  }
})
</script>

<template>
  <altcha-widget
    ref="altchaWidget"
    challengeurl="/api/altcha/challenge"
    hidelogo
    hidefooter
    style="--altcha-max-width:100%"
  />
</template>

And there you go, the contact page and the signup page are now protected by this altcha.

Now, does it work?

Altcha's limitations

The implementation was done yesterday. And unfortunately, I'm still seeing very suspicious signups on Pulse. So clearly, Altcha didn't do its job.

However, now that we know how it works, it's easier to understand why it doesn't work.

Altcha doesn't do any of the checks that Turnstile does:

  • no proof of space
  • no fingerprinting
  • no fingerprint verification with Cloudflare
  • no behavioral verification of the mouse click on the checkbox.

The only protection is the proof of work, which only costs the attacker time.

Now for Pulse, for reasons I don't understand, the person having fun creating accounts makes about 4 per day. The cost of the proof of work is negligible in this case.

So Altcha is not suited for this type of "slow attack".

Anyway, I'll have to find another workaround... And I'm open to your suggestions.

Enjoyed this article?

Subscribe to receive the latest articles directly in your inbox.