Supabase Integration

Use WordAuth as a passwordless login mechanism with Supabase Auth.

WordAuth replaces magic-link emails with human-readable word pairs delivered by SMS or email. The Supabase session is established using the standard verifyOtp flow, so it works with any Supabase project without extra configuration.

How the flow works

  1. 1User submits their email address.
  2. 2Your server route calls WordAuth /v1/generate with the user's email, which generates and delivers an OTP.
  3. 3Your UI prompts the user to enter the word pair.
  4. 4Your server route calls WordAuth /v1/validate to verify the code. If valid, it uses the Supabase admin client to generate a magic-link token_hash for that email.
  5. 5The client calls supabase.auth.verifyOtp({ token_hash, type: "magiclink" }) to establish the Supabase session.

Prerequisites

  • A WordAuth API key (WORDAUTH_API_KEY) — server-side only
  • A Supabase service role key (SUPABASE_SERVICE_ROLE_KEY) — server-side only, never expose to the client
  • npm install wordauth @supabase/supabase-js

Route 1 — Send the word pair

Create a server route that generates a WordAuth OTP and delivers it. The example below uses Next.js App Router, but the logic is the same in any framework.

app/api/auth/send-otp/route.ts

import { NextRequest, NextResponse } from "next/server"; import { WordAuth } from "wordauth"; const wordauth = new WordAuth(process.env.WORDAUTH_API_KEY!); export async function POST(req: NextRequest) { const { email } = await req.json(); if (!email) { return NextResponse.json({ error: "email is required" }, { status: 400 }); } try { // Generate an OTP and deliver it to the user's email. // Swap email for phone to use SMS delivery instead. const data = await wordauth.generateWithEmail(email); return NextResponse.json({ otp_id: data.otp_id, expires_at: data.expires_at, }); } catch (err: any) { return NextResponse.json({ error: err.message }, { status: err.status ?? 500 }); } }

Route 2 — Verify and create a session

Validate the word pair with WordAuth, then use the Supabase admin client to generate a magic-link token_hash for the email. Return that hash to the client so it can sign in.

app/api/auth/verify-otp/route.ts

import { NextRequest, NextResponse } from "next/server"; import { WordAuth, WordAuthError } from "wordauth"; import { createClient } from "@supabase/supabase-js"; const wordauth = new WordAuth(process.env.WORDAUTH_API_KEY!); // Use the service role key — this must stay server-side only. const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { autoRefreshToken: false, persistSession: false } }, ); export async function POST(req: NextRequest) { const { email, otp_id, code } = await req.json(); if (!email || !code) { return NextResponse.json({ error: "email and code are required" }, { status: 400 }); } // 1. Validate the word pair with WordAuth. try { const result = await wordauth.validate({ otp_id, code }); if (!result.valid) { return NextResponse.json( { error: result.message ?? "Invalid code" }, { status: 400 }, ); } } catch (err: any) { if (err instanceof WordAuthError) { return NextResponse.json({ error: err.message }, { status: err.status }); } throw err; } // 2. Use Supabase admin to generate a magic-link token for this email. // This creates (or looks up) the user and returns a sign-in link. const { data, error } = await supabaseAdmin.auth.admin.generateLink({ type: "magiclink", email, }); if (error || !data.properties?.hashed_token) { return NextResponse.json({ error: "Failed to create session" }, { status: 500 }); } return NextResponse.json({ token_hash: data.properties.hashed_token }); }

Client — Establish the Supabase session

Use the token_hash returned from your verify route to sign the user in via the standard Supabase client.

const { error } = await supabase.auth.verifyOtp({ token_hash: verifyData.token_hash, type: "magiclink", }); if (!error) { router.push("/dashboard"); }

Full React example

"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ); export function WordAuthLogin() { const [email, setEmail] = useState(""); const [otpId, setOtpId] = useState<string | null>(null); const [code, setCode] = useState(""); const [error, setError] = useState<string | null>(null); const router = useRouter(); // Step 1: generate and deliver the word pair async function sendCode() { setError(null); const res = await fetch("/api/auth/send-otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); const data = await res.json(); if (!res.ok) return setError(data.error); setOtpId(data.otp_id); } // Steps 2 + 3: validate and sign in async function verify() { setError(null); const res = await fetch("/api/auth/verify-otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, otp_id: otpId, code }), }); const data = await res.json(); if (!res.ok) return setError(data.error); const { error: supaErr } = await supabase.auth.verifyOtp({ token_hash: data.token_hash, type: "magiclink", }); if (supaErr) return setError(supaErr.message); router.push("/dashboard"); } if (!otpId) { return ( <div> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" /> <button onClick={sendCode}>Send Code</button> {error && <p>{error}</p>} </div> ); } return ( <div> <p>Enter the word pair we sent to {email}</p> <input type="text" placeholder="e.g. happening holiday" value={code} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code && verify()} autoFocus /> <button onClick={verify} disabled={!code}> Verify &amp; Sign In </button> {error && <p>{error}</p>} </div> ); }

OAuth providers

WordAuth works alongside standard Supabase OAuth. Users who prefer Google or GitHub can sign in via supabase.auth.signInWithOAuth — no changes needed.

const { error } = await supabase.auth.signInWithOAuth({ provider: "google", // or "github" options: { redirectTo: `${window.location.origin}/auth/callback`, }, });