theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev
Last month I deleted a password reset email template from a side project. It felt strange. That template had been the busiest piece of copy in the whole app, and I had never once looked at it on purpose.
The reset flow existed because passwords exist. Remove the password and a whole class of support tickets disappears with it. But "go passwordless" is not one decision. You face a menu of five or six, and each item fails differently.
This guide walks through that menu using theAuth, an open-source auth library for TypeScript (npm package @glinr/theauth). You will wire up magic links, email OTP, passkeys and TOTP two-factor, add a breached-password check for the apps that still need passwords, and finish with a decision table you can screenshot. I stick to what the docs and package source actually ship. Where theAuth leaves a job to you, I say so.
This is guide 2 of 8 in the theAuth guides. It stands on its own, so you can start right here. It assumes a working login like the one in guide 1, but the passkey and OTP code reads fine on its own.
| Guide | Title | Read it when |
|---|---|---|
| 1 | Add login to an existing Next.js app | You have an app with no auth yet |
| 2 | Passwordless login: passkeys, links, OTP (this guide) | You want to drop passwords or add 2FA |
| 3 | Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM | You sell to teams and companies |
| 4 | Migrate from Auth0 or Clerk | You already run another provider |
| 5 | Give every AI agent its own identity | You run AI agents and need to start somewhere |
| 6 | Cap agent spend and require human approval | Your agents spend money or act on risky things |
| 7 | Secure an MCP server for production | You expose tools over MCP |
| 8 | Build an audit trail for AI agent actions | Someone will ask what your agents did |
Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.
TL;DR
| Method | Best for | What the user does | Main catch |
|---|---|---|---|
| Magic link | Low-frequency apps, B2B invites | Clicks a link in email | Single use, expires in 15 minutes by default |
| Email OTP | Mobile, or when links get mangled | Types a 6-digit code | Attempt limits, email delivery speed |
| Passkey | Daily-use apps, phishing resistance | Touch ID, Face ID, security key | Needs a signed-in user to register |
| TOTP 2FA | Adding a second factor to anything | Types an authenticator code | You wire it into sign-in yourself |
| Breach check | Apps that keep passwords | Nothing, it runs at sign-up | Fails open by default |
If you only have ten minutes, ship magic links first, add a passkey enrollment prompt after the first sign-in, and put TOTP behind a setting for users who want it.
Prerequisites
You need Node 20 or newer, a Postgres database, and an email provider. My examples use Resend, but any provider works because theAuth hands you the address and the code or link and you do the sending.
You also need a session secret of at least 32 characters. Every email-based method below refuses to start without auth.session configured, because each one issues a session when verification succeeds.
pnpm add @glinr/theauth @glinr/theauth-nextjs @simplewebauthn/browser
openssl rand -base64 48 # use the output as SESSION_SECRET
I mount everything in Next.js App Router, but the same plugin config works with the Express, Hono, Fastify and other adapters listed on the adapters overview. If you want a scaffolded app instead, the quickstart has a pnpm create @glinr/theauth-app command that sets up a Next.js SaaS template.
How to choose before you code
Each method answers a different question, so pick by the question.
Magic links answer "can this person read this inbox right now?" Email OTP answers the same thing, but with a code the user types instead of a link they click. Passkeys answer "is this person holding a device I enrolled before?" TOTP answers "does this person also hold an authenticator app?"
The first two prove inbox control. That is only as strong as the user's email account. The passkey proves device possession plus a local biometric or PIN, and the browser ties it to your domain, so a lookalike phishing site cannot reuse it. TOTP adds a second proof, but a user can still type a TOTP code into a fake site, so TOTP does not resist phishing.
My default stack for a new app is this. Magic link or OTP as the front door. Passkey as the upgrade offered after first sign-in. TOTP for accounts that hold money or admin power. A breach check only if you accept passwords at all.
Step 1: Create the instance and mount the routes
Start with the shared module. All the examples below add to this one file.
// lib/theauth.ts
import { createTheAuth } from '@glinr/theauth';
export const theauth = await createTheAuth({
database: { provider: 'postgres', url: process.env.DATABASE_URL! },
baseUrl: process.env.APP_URL!,
auth: { session: { secret: process.env.SESSION_SECRET! } },
plugins: [],
});
Then add the catch-all route so the endpoints exist. The Next.js adapter page covers the full options.
// app/api/theauth/[...theauth]/route.ts
import { theAuthNextjs } from '@glinr/theauth-nextjs';
import { theauth } from '@/lib/theauth';
const handlers = theAuthNextjs(theauth);
export const GET = handlers.GET;
export const POST = handlers.POST;
export const PATCH = handlers.PATCH;
export const DELETE = handlers.DELETE;
export const OPTIONS = handlers.OPTIONS;
The default mount path is /api/theauth. Every fetch call below assumes it. If you mount elsewhere, pass basePath to the adapter and update the URLs.
Step 2: Magic links
Magic links are the cheapest passwordless method to ship. The magic link docs describe one plugin and one callback.
import { magicLink } from '@glinr/theauth/auth';
// inside createTheAuth({ plugins: [...] })
magicLink({
appUrl: `${process.env.APP_URL}/api/theauth`,
sendMagicLink: async (email, _token, url) => {
await resend.emails.send({
from: 'auth@example.com',
to: email,
subject: 'Your sign-in link',
html: `<a href="${url}">Sign in</a>. This link expires in 15 minutes.`,
});
},
}),
Two details tripped me up on first read.
First, appUrl is not your marketing site. theAuth prepends it to the callback path, so it must point at wherever the verify endpoint is reachable. With the default mount, that is your public origin plus /api/theauth.
Second, the link lands on GET /auth/magic-link/verify?token=..., and that endpoint returns JSON. It does not set a cookie or redirect. Your app has to take session.token and store it, then send the user somewhere useful. In practice that means a small page of your own that calls the verify endpoint and handles the result.
Send and verify from the client
// request the link
await fetch('/api/theauth/auth/magic-link/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' }),
});
// 200 { sent: true }
// on your callback page, with ?token= in the URL
const res = await fetch(`/api/theauth/auth/magic-link/verify?token=${token}`);
if (res.status === 401) {
// invalid, expired or already used: offer "send a new link"
}
const { user, session } = await res.json();
// user: { id, email }, session: { token, expiresAt }
What happens with unknown emails
A send for an unknown address creates a user record for it. That means the same form serves sign-in and sign-up, which users like. It also means anyone can create accounts for any address by typing it in. If that matters for you, put a CAPTCHA in front of the form. theAuth ships a captcha module for Turnstile, hCaptcha and reCAPTCHA. The module is not a plugin, so you call it from your own route handler before you send the link.
Rate limits
The send endpoint allows 5 requests per minute per IP, and extra requests get 429 with a Retry-After header. Show a visible countdown on the resend button. Users who click "resend" six times in a row are the ones who file tickets.
The tokenExpiry option defaults to 900 seconds. Email can arrive slowly, so I would not drop it below 600.
Step 3: Email OTP
OTP is the same idea with a different payload. Use it when the user is on a phone and the link would open the wrong browser, or when corporate email scanners pre-click links and burn single-use tokens. That second problem is real and nasty. A scanner that fetches the URL consumes the token before the human sees the email. A typed code avoids it.
The email OTP page covers the plugin.
import { emailOtp } from '@glinr/theauth/auth';
emailOtp({
codeLength: 6,
codeExpiry: 300,
maxAttempts: 5,
sendOtp: async (email, code) => {
await resend.emails.send({
from: 'auth@example.com',
to: email,
subject: `Your code: ${code}`,
html: `<p>Your sign-in code is <strong>${code}</strong>. It expires in 5 minutes.</p>`,
});
},
}),
The three numeric options above are the documented defaults, so you can drop them. I wrote them out so you can see the knobs.
await fetch('/api/theauth/auth/email-otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const res = await fetch('/api/theauth/auth/email-otp/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
});
const { user, session } = await res.json();
The behaviors worth knowing are these. Requesting a new code deletes the earlier one for that address. After maxAttempts wrong guesses the code dies and the user needs a fresh one. Verify allows 10 requests per minute per IP. A wrong, expired or exhausted code all return 401, so your UI cannot tell them apart and should not try.
Link or code, which one
I ship both on the same screen in some apps. The user types an email, and the message holds a link and a code. You can do this today by calling both plugins' send endpoints, but you would send two emails. If you want one email with both, you would need to generate that message yourself, and the docs do not describe a combined flow. I would pick one per app and move on.
Step 4: Passkeys
Passkeys are the method I want most users on, and the one with the most setup. The passkey docs lay out the plugin and the two ceremonies.
import { passkey } from '@glinr/theauth/auth';
passkey({
rpName: 'My App',
rpId: 'example.com',
origin: process.env.APP_URL!,
}),
Get rpId and origin right or nothing works. The relying party ID must be a registrable domain suffix of the origin. For an app served from the app.example.com host, both app.example.com and example.com are valid. The server compares origin exactly against what the browser sends, so a missing www or a wrong port fails the check. For local work, set origin to the exact dev origin, port included.
The catch: registration needs a session
Registration needs an authenticated user. The docs state it plainly: the user must hold a session before registering a passkey. Passkeys are an upgrade path, not a first sign-up method. A new user arrives by magic link or OTP, and then you offer the passkey.
I like this shape anyway. The email step proves the address, the passkey makes every later visit one tap.
Register a passkey
import { startRegistration } from '@simplewebauthn/browser';
const optionsRes = await fetch('/api/theauth/auth/passkey/register/options', {
method: 'POST',
credentials: 'include',
});
const options = await optionsRes.json();
const credential = await startRegistration(options);
await fetch('/api/theauth/auth/passkey/register/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response: credential }),
});
The verify body wraps the browser credential in a response field. Miss that wrapper and you get a failure that looks like a bad credential.
Sign in with a passkey
import { startAuthentication } from '@simplewebauthn/browser';
const optionsRes = await fetch('/api/theauth/auth/passkey/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const options = await optionsRes.json();
const assertion = await startAuthentication(options);
const res = await fetch('/api/theauth/auth/passkey/authenticate/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response: assertion }),
});
const { user, session } = await res.json();
The options call takes an optional userId, not an email. Send an empty body and the browser offers any discoverable passkey for your domain. That is the setup you want for a "Sign in with a passkey" button that needs no username field.
With auth.session configured, verify returns the user and session and sets a theauth_session cookie. Without a session manager you get a raw { userId, credential } and no session. A failed assertion returns 401.
Tuning
The options table in the docs lists three knobs I touch. userVerification defaults to 'preferred', so set 'required' if you want a biometric or PIN check every time. attestation defaults to 'none', which is right for almost every consumer app. challengeTimeout defaults to 60000 milliseconds and caps at 300000.
Do not strand users
A user can list and delete their passkeys. If they delete the last one and have no other sign-in method, the app locks them out. Count credentials before you let the delete call through, or make sure email sign-in is still available. In my setup it always is, so a lost phone costs one magic link and nothing more.
const { credentials } = await (
await fetch('/api/theauth/auth/passkey/credentials', { credentials: 'include' })
).json();
// each: { id, credentialId, deviceName?, createdAt, lastUsedAt, ... }
Step 5: TOTP two-factor
The two-factor docs describe a twoFactor() plugin with enrollment, verification and backup codes. Read the second paragraph of that page before you write any code, because it sets expectations.
theAuth stores and verifies the secret and backup codes. It does not sit inside your sign-in flow. No built-in "two factor required" challenge exists on the primary sign-in endpoints. You decide when to ask for a code.
That is a deliberate tradeoff, and you should know what you are signing up for. You get full control over where the prompt appears. You also own the gate, so a bug in your code is a bypass.
import { twoFactor } from '@glinr/theauth/auth';
twoFactor({ appName: 'My App' }),
Enroll
const res = await fetch('/api/theauth/auth/2fa/enroll', {
method: 'POST',
credentials: 'include',
});
const { secret, uri, backupCodes } = await res.json();
Render uri as a QR code with any QR library. Show backupCodes exactly once. theAuth keeps only hashes, so you cannot show them again later. Calling enroll a second time replaces the secret and the backup codes and turns 2FA off until the user confirms the new secret.
Enrollment is not done until the user proves the app works. Ask for a code and post it to the verify endpoint.
await fetch('/api/theauth/auth/2fa/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '482910' }),
});
// 200 { valid: true, activated: true }
Gate sign-in yourself
Here is the pattern the docs describe in prose, written out. Finish the primary sign-in on your server. Do not hand the session to the browser yet. Hold the user in a pending state, collect a code, verify it, and only then release the session.
The verify endpoint resolves the user from an Authorization: Bearer header, so your server can call it with the pending token. This sketch uses a pending map as a stand-in for your own store (a signed cookie, Redis, a table).
// server code, after the magic link or OTP verify succeeded
const pending = new Map<string, { token: string; expiresAt: string }>();
async function beginSecondFactor(session: { token: string; expiresAt: string }) {
const ticket = crypto.randomUUID();
pending.set(ticket, session);
return ticket; // send this to the browser, not the session token
}
async function finishSecondFactor(ticket: string, code: string) {
const session = pending.get(ticket);
if (!session) return null;
const res = await fetch(`${process.env.APP_URL}/api/theauth/auth/2fa/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.token}`,
},
body: JSON.stringify({ code }),
});
const { valid } = await res.json();
if (!valid) return null; // wrong code is a 200 with valid: false
pending.delete(ticket);
return session;
}
Check that valid field. A wrong code returns HTTP 200 with valid: false, so a status check alone lets everyone through. I would have shipped that bug if the docs had not called it out.
If you configure the top-level totp key instead of the plugin, theauth.totp.verify(userId, code) does the same check in-process and returns { valid, usedBackupCode? }. The docs say that module's own request handler takes a userId with no session check, so keep it off the public internet.
Backup codes
Enrollment makes ten backup codes of eight characters. The alphabet leaves out 0, O, 1 and I so people can read them off paper. A backup code works in place of a TOTP code at verify and disable, and each one works once. Regenerating via POST /auth/2fa/backup-codes kills the old set and requires a current code.
The verifier accepts codes one 30-second step either side of now, which tolerates modest clock drift on a phone.
Step 6: Skip the prompt on trusted devices
Asking for a code on every sign-in gets old, and users pick weaker habits to avoid it. The trusted device module lets you skip the prompt on a device the user already verified.
The module stands alone and does not wire itself into twoFactor. You call it in your own flow.
import { createTrustedDeviceModule } from '@glinr/theauth/auth';
const trustedDevice = createTrustedDeviceModule(
{
secret: process.env.TRUSTED_DEVICE_SECRET, // separate from the session secret
trustDurationSeconds: 30 * 24 * 60 * 60,
maxDevices: 10,
},
theauth.db,
);
After a successful second factor, mark the device. Before prompting on the next sign-in, check it.
// after the code is verified
const fingerprint = await trustedDevice.generateFingerprint(request);
await trustedDevice.trustDevice(user.id, fingerprint);
// on the next sign-in, before asking for a code
const fp = await trustedDevice.generateFingerprint(request);
if (await trustedDevice.isTrusted(user.id, fp)) {
// skip the second factor and release the session
}
Three honest notes. If you leave out secret, theAuth uses a random per-process key, so every restart un-trusts every device. Set it. Rotating that secret later is also your kill switch, since it invalidates all trusted devices at once. And the fingerprint comes from four request headers (user-agent, accept-language, accept-encoding, accept), so a browser update changes it and the user sees one extra prompt. I treat it as a convenience feature and not as strong device identity.
You also get revokeDevice, revokeAllDevices and listDevices for a "your devices" settings page. Call revokeAllDevices on password change.
Step 7: Breached-password checks, if you keep passwords
Not every app can go fully passwordless. Enterprise customers ask for passwords. Legacy users have them. If you keep passwords, check them against known breaches.
The breach checking page covers createHibpModule, which calls the Pwned Passwords range API. It uses k-anonymity: only the first 5 characters of the SHA-1 hash leave your server. The API returns roughly 500 candidate suffixes and your server looks for a match locally.
import { createHibpModule, HibpBreachedError } from '@glinr/theauth/auth';
const hibp = createHibpModule({
threshold: 0,
timeoutMs: 5000,
onError: 'allow',
});
async function handleSignUp(username: string, password: string) {
try {
await hibp.enforce(password);
} catch (err) {
if (err instanceof HibpBreachedError) {
return { error: `That password appeared in ${err.count} known breaches. Pick another.` };
}
throw err;
}
return theauth.username?.signUp({ username, password });
}
The module is standalone. It does not hook into the username module on its own, so you call enforce() before signUp. Forget that and nothing gets checked.
Use check() when you want to warn without blocking. It returns the raw count and ignores threshold.
Fail open or fail closed
The default onError: 'allow' means a slow or unreachable API lets the password through. That keeps sign-up working during an outage. It also means an attacker who can block your egress to the API skips the check. For most apps I keep the default. If you run a regulated product, set 'block' and accept that enforce() will throw HibpApiError during outages.
Putting it together
Here is the final config with every piece in one place.
// lib/theauth.ts
import { createTheAuth } from '@glinr/theauth';
import { magicLink, passkey, twoFactor } from '@glinr/theauth/auth';
export const theauth = await createTheAuth({
database: { provider: 'postgres', url: process.env.DATABASE_URL! },
baseUrl: process.env.APP_URL!,
auth: { session: { secret: process.env.SESSION_SECRET! } },
plugins: [
magicLink({
appUrl: `${process.env.APP_URL}/api/theauth`,
sendMagicLink: async (email, _token, url) => {
await sendEmail(email, `Sign in: ${url}`);
},
}),
passkey({
rpName: 'My App',
rpId: 'example.com',
origin: process.env.APP_URL!,
}),
twoFactor({ appName: 'My App' }),
],
});
The user journey this gives you:
- New visitor types an email and clicks the magic link.
- Your callback page stores the session and shows a "Add a passkey" prompt.
- Next visit, the passkey button signs them in with one tap.
- Users with sensitive roles enroll TOTP, and your gate asks for a code on untrusted devices.
Rate limiting deserves a mention. The send and verify endpoints already carry their own per-IP caps. If you want per-route limits on the rest of /auth/*, the rate limiting page explains the opt-in rateLimit() plugin and notes that it limits nothing until you add it.
Troubleshooting and gotchas
Startup throws about auth.session
The magic link and email OTP plugins throw at startup without auth.session, and the secret must be at least 32 characters. Generate a longer one.
The magic link opens but nothing signs in
The verify endpoint returns JSON and sets no cookie. Your callback page must read session.token, store it, and redirect. If you point the email link directly at the API URL, the user sees raw JSON.
Passkey registration fails on localhost
Check origin first. It must match the exact origin in the address bar, including the port. Then check that rpId is a suffix of the host. Use the dev origin in config and keep it out of production builds.
Passkey sign-in returns 401
A 401 on authenticate verify means the assertion failed. Confirm you wrapped the assertion in { response: assertion }, and confirm the same rpId you run now produced the credential. Changing rpId later orphans every enrolled passkey.
Everyone passes the 2FA gate
You checked the status code. The verify endpoint returns 200 with valid: false on a wrong code. Read the field.
Trusted devices reset after deploys
You omitted secret. Set TRUSTED_DEVICE_SECRET to a stable value.
Users report "link expired" at random
Corporate mail scanners often fetch links before the human clicks. Single-use tokens die on that fetch. Switch those users to email OTP, or make your callback page require a button click before it calls verify.
Go deeper
Each of these pages goes further than I could here.
- Passkey for the credential list, delete endpoint and full options table.
-
Magic link for
tokenExpiry,callbackPathand the programmatictheauth.magicLinkAPI. - Email OTP for code length, expiry and attempt limits.
- Two-factor auth for disable, status and backup code regeneration.
- Trusted devices for revocation and listing.
- Password breach checking for self-hosted mirrors and fail-closed mode.
- Sessions for cookie and JWT session handling after sign-in.
- Email templates for the messages these flows send.
- Authentication overview for every other sign-in method.
FAQ
Are passkeys better than passwords?
For most users, yes. The browser ties a passkey to your domain, so a phishing site cannot collect something reusable. It also removes password reuse and credential stuffing. The cost is recovery. You need a second way back in, such as email sign-in, for the day a user loses every device.
Should I use magic links or email OTP?
Choose links when users read mail on the same device they sign in on. Pick OTP when they cross devices, or when mail scanners eat your links. Both prove inbox control and nothing more, and both share the 5 sends per minute per IP cap on the send endpoint.
Does theAuth enforce two-factor on sign-in automatically?
No. It stores and verifies TOTP secrets and backup codes, and you decide where the challenge goes in your sign-in flow. That gives you control and puts the gate in your hands. The two-factor page says this directly.
Can a new user sign up with a passkey alone?
Not with the documented endpoints. Passkey registration requires an authenticated user. Have the user sign in once by magic link or OTP, then offer the passkey.
Is TOTP phishing resistant?
No. A user can type a TOTP code into a fake page, and the attacker can replay it within its 30-second window. TOTP stops stolen-password attacks. A passkey stops phishing. If phishing is your main risk, push users toward passkeys.
Do I still need a breach check if I go passwordless?
Only if you accept passwords anywhere. If every account uses links, codes and passkeys, there is no password to check.
What I would like to know
If you shipped passwordless login in production, which method did your users actually pick, and which one did you end up removing? I am especially curious whether anyone saw the mail scanner problem at scale.
Try it yourself
The fastest way in is the quickstart. If this guide saved you time, a star on GitHub helps other developers find the project, and the docs cover every option used above. More about the project lives at theauth.dev.
Next: guide 3, multi-tenant SaaS auth. The full list sits in the table at the top of this page.
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
Every login method fails somewhere, so pick the one whose failure you can explain to a user.
Top comments (1)
tr.ee/dev-to