Email OTP login for a Node API, without Auth0 or Clerk
Two endpoints give you passwordless email login: send_code then verify. Runnable curl and Node 22 code, plus what the OTP path does not cover.
Passwordless email login on Infrai is two calls. POST /v1/auth/email/send_code mails a one-time code; POST /v1/auth/email/verify checks it and returns a session. Verify is the login — if the address has never been seen before, the user is created in the same request, so you don’t need a separate signup route at all.
That collapsing of signup and login is why the OTP path is usually the fastest way off a homegrown password table. There’s no password to store, reset, rotate or breach, and Infrai keeps the code generation, expiry and rate limiting on its side.
The happy path, end to end
Step one takes just the address. purpose defaults to verify and accepts login; locale controls the language the email is rendered in.
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/send_code" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "purpose": "login", "locale": "en"}'
{
"ok": true,
"data": { "sent": true, "expires_in": 600, "already_verified": false }
}
expires_in is the code’s life in seconds — ten minutes here. Show that number in your UI rather than hardcoding “expires shortly”, and read it from the response so it stays right if the platform tunes it.
Step two exchanges the code for tokens.
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/verify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "code": "418293"}'
{
"ok": true,
"data": {
"verified": true,
"created": true,
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"session_id": "au_ses_7Uu2kQxWvR4mBn8dTcYs",
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImluZnJhaS1hdXRoLWVkMjU1MTktdjEi...",
"refresh_token": "au_rft_9wQ1zV6pLkS3dHyBnMfE",
"expires_at": "2026-09-28T02:24:30Z"
}
}
created: true means this was a first-time signup. That single boolean is what you branch on for onboarding — send the welcome email, provision the workspace, fire the analytics event. On a returning user it’s false and you skip all of it.
There’s a third field worth knowing about: pass login: false to verify when you only want to prove the address is real without starting a session — useful when the person is already signed in and is adding a second address.
The Node handler
import express from "express";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const app = express();
app.use(express.json());
async function callAuth(path, body) {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) {
const err = new Error(payload.error?.message ?? "auth call failed");
err.code = payload.error?.code;
err.status = res.status;
throw err;
}
return payload.data;
}
app.post("/login/start", async (req, res) => {
try {
const { sent, expires_in } = await callAuth("/v1/auth/email/send_code", {
email: String(req.body.email ?? "").trim().toLowerCase(),
purpose: "login",
locale: req.headers["accept-language"]?.startsWith("zh") ? "zh-CN" : "en",
});
res.json({ sent, expiresInSeconds: expires_in });
} catch (e) {
// AUTH_RATE_LIMIT here means someone is cycling the same address.
const status = e.code === "AUTH_RATE_LIMIT" ? 429 : 400;
res.status(status).json({ error: e.code ?? "send_failed" });
}
});
app.post("/login/finish", async (req, res) => {
try {
const data = await callAuth("/v1/auth/email/verify", {
email: String(req.body.email ?? "").trim().toLowerCase(),
code: String(req.body.code ?? "").trim(),
});
if (data.created) await onboard(data.user_id);
res.json({ accessToken: data.access_token, refreshToken: data.refresh_token, isNew: data.created });
} catch (e) {
res.status(401).json({ error: e.code ?? "AUTH_CODE_INVALID" });
}
});
async function onboard(userId) {
console.log(`new user ${userId} — provision workspace here`);
}
app.listen(3000, () => console.log("listening on :3000"));
Two things in there are deliberate. Addresses are lowercased before they go out, because Ada@Example.com and ada@example.com should not become two accounts. And a wrong code is surfaced as 401 with the platform’s own AUTH_CODE_INVALID, so your client can tell “retype the code” apart from “ask for a new one”.
Rate limits and the abuse shape you should expect
The route that costs you nothing is the one attackers like most. Someone pointing a script at send_code with a list of addresses is the predictable abuse, and the platform answers AUTH_RATE_LIMIT when a single address or account goes over. Don’t retry that automatically — surface it, and add a client-side cooldown so a user mashing “resend” doesn’t burn their own budget.
In practice a 30-second resend lock plus a per-IP cap in front of /login/start removes almost all of it.
What the OTP path doesn’t cover
It doesn’t give you a password. If your product needs classic email-and-password login — because enterprise buyers ask for it, or because your mobile app caches credentials — you want POST /v1/auth/user/create with a password, then POST /v1/auth/session/create for the login. The two models coexist on the same user record: OTP-verify an address today, add a password later, and it’s still one user_id.
It also doesn’t give you a UI. Clerk ships a hosted sign-in component that handles the code input, the resend timer and the error states for you, and if you want that today you’d be better off with Clerk than rebuilding it from these endpoints. SuperTokens is the honest comparison if you’d rather self-host the whole identity store. What you’re trading is convenience for a plain HTTP contract you can call from anything.
Cost and the thing next door
send_code, verify, user/create and session/create all report billing_class: free in discovery — this flow isn’t billed per call. Identity is metered by monthly active user, the model Auth0 and Clerk also use, so GET /v1/account/usage is where you see what your account accrued; verified 2026-09-21. Read it from your own account rather than trusting a figure in an article, and expect the direction of travel to be downward — platform rates get cut and campaigns run.
The part that’s hard to copy is what’s already on the same key when the login works. The welcome email goes out through POST /v1/email/send, the onboarding job goes on POST /v1/queue/publish, and a failure in either lands in POST /v1/errors/capture — no second vendor, no second key, one invoice.
Verify your account is wired up
Before you write any of the above, confirm the surface is live for your key:
curl -sS "https://api.infrai.cc/v1/auth/oauth/providers" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A 200 with a providers array means the auth module is answering for this account. It’s the cheapest possible smoke test — a free read, no side effects.