useHUMA
+ Supabase.
Protect your Supabase app from bots and fake signups — without CAPTCHAs. A single API call returns a real-time trust score (0–100). No PII stored. Under 200ms.
Sign up at humaverify.com and grab your API key from the dashboard. 14-day free trial — no credit card required.
HUMA_API_KEY=huma_live_xxxxxxxxxxxxxxxxxxxxxxxxAdd this to your Supabase Edge Function secrets or .env.local.
Add this to your layout.tsx or _app.tsx:
<script src="https://humaverify.com/huma.js"></script>Then collect the behavioral signals on form submit:
const handleSubmit = async (e) => {
e.preventDefault();
// Anonymous behavioral signals — no PII.
// If huma.js was blocked by an extension this is undefined. Send it as
// undefined rather than {}: an empty object means "we watched and saw
// nothing", which scores as a bot, while a missing field means "we have
// no data", which your server should let through. A privacy extension is
// far more common on a developer's machine than a bot on your form.
const signals = window.Huma ? window.Huma.debug().features : undefined;
// Call your own endpoint. Do NOT pass this to supabase.auth.signUp():
// it accepts a fixed shape and silently drops anything it does not know,
// so the signals would never leave the browser.
await fetch('/functions/v1/verify-human', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, signals }),
});
};Create supabase/functions/verify-human/index.ts:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
serve(async (req) => {
const { signals, email, password } = await req.json()
// Verify with useHUMA
const humaRes = await fetch('https://humaverify.com/api/v1/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Deno.env.get('HUMA_API_KEY')}`,
},
// Pseudonymous id, never the raw email: it is stored with the record.
// Hash it, or use whatever internal id you already have.
body: JSON.stringify({ userId: await sha256(email), sessionData: signals }),
})
// Fail open. If useHUMA is rate limited, down, or slow, this must not become
// your outage: let the request through. Reading .human off a non-200 body
// gives undefined, and !undefined is true, which would 403 every real user
// the moment anything went wrong on our side.
let human = true
if (humaRes.ok) {
const result = await humaRes.json()
human = result.human
// Tells you when a field name did not land. Log it while integrating.
if (result.warnings) console.warn('[huma]', result.warnings.join(' '))
}
if (!human) {
return new Response(
JSON.stringify({ error: 'Verification failed. Please try again.' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
)
}
// Proceed with Supabase signup
const { createClient } = await import('https://esm.sh/@supabase/supabase-js@2')
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data, error } = await supabase.auth.admin.createUser({
email, password, email_confirm: true,
})
if (error) return new Response(
JSON.stringify({ error: error.message }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
return new Response(
JSON.stringify({ user: data.user }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
})Add useHUMA to your app/signup/actions.ts:
'use server'
export async function signUp(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
// This field does not exist unless you create it. Add a hidden input to the
// form and fill it from the collector before submit:
// <input type="hidden" name="huma_signals" />
// el.value = JSON.stringify(window.Huma.debug().features)
const raw = formData.get('huma_signals')
const signals = typeof raw === 'string' && raw ? JSON.parse(raw) : undefined
const humaRes = await fetch('https://humaverify.com/api/v1/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.HUMA_API_KEY}`,
},
body: JSON.stringify({ userId: email, sessionData: signals }),
})
// Fail open, same reason as above: a 429 or a 503 must not block your users.
let human = true
if (humaRes.ok) human = (await humaRes.json()).human
if (!human) {
return { error: 'Verification failed. Please try again.' }
}
const supabase = createClient()
const { error } = await supabase.auth.signUp({ email, password })
if (error) return { error: error.message }
return { success: true }
}confidence is a decimal from 0 to 1. The score field on the session heartbeat is the same number as 0 to 100, which is the only place that scale appears. You rarely need either: every verify response also returns human, already compared against your account threshold, and the threshold itself so you never have to guess which number decided it.