Scribase Mail docs

Magic links and OTP login

Passwordless sign-in for your app with Scribase Mail: magic links, 6-digit codes, email verification and password reset, ending in an Ed25519 JWT you verify against a JWKS. Next.js and plain JavaScript examples.

Scribase Mail includes a small sign-in service so you do not need Clerk's email tier or your own token tables. Your app calls one endpoint to email a link or a code; when the person clicks or types it, they get a signed session token (EdDSA/Ed25519 JWT) that your backend verifies with the public key at /.well-known/jwks.json. Emails go out from your verified domain, DKIM-signed, and do not count toward your sending quota (up to 10,000/month on Free).

Concepts

No endpoint here needs an API key. Anti-enumeration: once a request is well-formed the answer is always 200, whether or not we know the address. Limits: 5 requests per hour per address per project, 30 per hour per IP (429 with Retry-After).

Endpoints

MethodPathBodyReturns
POST/auth/magic-link{ "email", "redirect_to", "project" }{ "ok": true, "message", "expires_in": 900 }
POST/auth/email-verificationsamesame; JWT purpose is email_verify
POST/auth/password-resetsamesame; JWT purpose is password_reset, 15-minute token
GET/auth/verify?token=303 redirect to redirect_to#access_token=...&token_type=Bearer&expires_in=...&purpose=..., or the Session as JSON when Accept: application/json
POST/auth/otp{ "email", "project" }{ "ok": true, "message", "expires_in": 600, "max_attempts": 5 }
POST/auth/otp/verify{ "email", "project", "code" }Session; 400 invalid_code with attempts left, 400 too_many_attempts after the 5th
POST/auth/token/verify{ "token", "audience"? }{ "valid": true, "claims", "expires_in" } or 401
GET/.well-known/jwks.json{ "keys": [{ "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "kid", "x" }] }
GET/POST/auth/admin/projectsadmin bearer tokenlist / create
GET/PATCH/auth/admin/projects/{id}admin bearer tokenread / update

Errors are { "error": "<code>", "message": "..." }: invalid_email, not_found (unknown project), invalid_redirect (origin not allowed), invalid_token, invalid_code, too_many_attempts, rate_limited, unauthorized.

Links are single use and expire after 15 minutes; a second visit returns 400 invalid_token. Codes are 6 digits, expire after 10 minutes and allow 5 attempts. Tokens are stored only as SHA-256 hashes; comparisons are constant-time.

JWT claims

{
  "iss": "scribase-mail-auth",
  "sub": "7892cff8-aadf-41ec-97b3-...",
  "aud": "acme",
  "email": "jane@example.com",
  "email_verified": true,
  "project": "acme",
  "purpose": "magic_link",
  "iat": 1794518113,
  "exp": 1794521713,
  "jti": "745bb32e-..."
}

sub is a stable user id per (project, email). Always check aud equals your project's jwt_audience and, for reset flows, that purpose is password_reset.

Server action that starts the flow:

// app/login/actions.ts
"use server";
import { ScribaseMail } from "@scribase/mail";
const mail = new ScribaseMail();

export async function sendLink(formData: FormData) {
  const email = String(formData.get("email"));
  await mail.auth.sendMagicLink({ email, redirectTo: `${process.env.APP_URL}/auth/callback`, project: "acme" });
  return { sent: true }; // always; never reveal whether the address exists
}

The link lands on /auth/callback#access_token=.... The token travels in the URL fragment so it never reaches server logs; a tiny client page moves it into a cookie through your own API route:

// app/auth/callback/page.tsx
"use client";
import { useEffect } from "react";
export default function Callback() {
  useEffect(() => {
    const params = new URLSearchParams(window.location.hash.slice(1));
    const token = params.get("access_token");
    if (!token) return;
    fetch("/api/session", { method: "POST", body: JSON.stringify({ token }) }).then(() => (window.location.href = "/app"));
  }, []);
  return <p>Signing you in…</p>;
}
// app/api/session/route.ts
import { verifyJwt, JwtVerificationError } from "@scribase/mail";
import { cookies } from "next/headers";

export async function POST(req: Request) {
  const { token } = await req.json();
  try {
    const claims = await verifyJwt(token, {
      jwksUrl: "https://mail-api.scribase.com/.well-known/jwks.json",
      audience: "acme",
    });
    (await cookies()).set("session", token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: claims.exp - claims.iat });
    return Response.json({ user: claims.sub, email: claims.email });
  } catch (e) {
    if (e instanceof JwtVerificationError) return new Response("invalid", { status: 401 });
    throw e;
  }
}

Protect routes in middleware.ts by calling verifyJwt on the cookie (Edge runtime works; the SDK uses WebCrypto). Or keep JWT verification off your box entirely with POST /auth/token/verify.

OTP code in plain JavaScript

<form id="start"><input name="email" type="email" required><button>Send code</button></form>
<form id="finish" hidden><input name="code" inputmode="numeric" pattern="\d{6}" required><button>Sign in</button></form>
<script type="module">
  const API = "https://mail-api.scribase.com";
  const start = document.getElementById("start"), finish = document.getElementById("finish");
  let email = "";
  start.onsubmit = async (e) => {
    e.preventDefault();
    email = new FormData(start).get("email");
    await fetch(`${API}/auth/otp`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, project: "acme" }) });
    start.hidden = true; finish.hidden = false;
  };
  finish.onsubmit = async (e) => {
    e.preventDefault();
    const code = new FormData(finish).get("code");
    const res = await fetch(`${API}/auth/otp/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, project: "acme", code }) });
    const body = await res.json();
    if (!res.ok) { alert(body.message); return; }      // "That code is not right. 4 attempts left."
    await fetch("/api/session", { method: "POST", body: JSON.stringify({ token: body.access_token }) });
    location.href = "/app";
  };
</script>

On the server, verify access_token the same way as above (JWKS or POST /auth/token/verify).

curl -X POST https://mail-api.scribase.com/auth/magic-link \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "redirect_to": "https://acme.com/welcome", "project": "acme" }'
# {"ok":true,"message":"If that address can receive mail, a link is on its way.","expires_in":900}

# the email contains https://mail-api.scribase.com/auth/verify?token=...
curl -H "Accept: application/json" "https://mail-api.scribase.com/auth/verify?token=$TOKEN"
# {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600,"purpose":"magic_link","user":{...},"redirect_to":"https://acme.com/welcome"}

Email verification and password reset

Same body as a magic link. Use /auth/email-verification after a classic sign-up to prove the address (email_verified becomes true in later tokens). Use /auth/password-reset to issue a 15-minute token whose purpose is password_reset; accept a new password only when that claim is present.

Templates

Built-in plain, accessible HTML and text templates say what the email is for, show the link or code, and say when it expires. Per-project you can override the subject line per purpose (subject_overrides: { "otp": "Your Acme code" }); the app_name appears in the sender name and body. Custom bodies with {{code}}, {{link}}, {{app_name}}, {{expires_in}} are next.

Railyard console and Scribase Auth

The Scribase console and Scribase Auth projects sign in through these same endpoints; the Supabase-compatible path for Supabase projects is on Use as Supabase's mail server.