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
- Project: one row per app. Has a
slug(used in every request),app_name(shown in emails),from_address(on a verified domain),jwt_audience(defaults to the slug) andallowed_redirect_origins(where links may send people). Create it from the dashboard orPOST /auth/admin/projects. - Flows: magic link, OTP code, email verification, password reset. All end in the same
Session. - Session:
{ access_token, token_type: "Bearer", expires_in, purpose, user: { id, email, email_verified_at } }. The token is valid for 1 hour (15 minutes forpassword_reset).
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
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /auth/magic-link | { "email", "redirect_to", "project" } | { "ok": true, "message", "expires_in": 900 } |
| POST | /auth/email-verification | same | same; JWT purpose is email_verify |
| POST | /auth/password-reset | same | same; 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/projects | admin bearer token | list / create |
| GET/PATCH | /auth/admin/projects/{id} | admin bearer token | read / 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.
Magic link in Next.js (App Router)
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).
Magic link with curl
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.