Passwordless Authentication: Magic Links, Codes and Passkeys
Passwordless authentication signs users in without a password, by proving they control something else: an e-mail inbox (magic links and one-time codes), a phone number (SMS codes), or a device with a cryptographic key (passkeys). It removes password reuse and credential stuffing, the most common ways accounts get taken over, and it removes the "forgot password" flow. Each method has different trade-offs in security and user experience, and most apps end up offering two.
This guide explains how each method works, compares them, and shows how to implement them.
#The options at a glance
| Method | User proves | Phishing resistant | Friction | Main risk |
|---|---|---|---|---|
| Magic link | Access to inbox | No | Switch to e-mail, click | Link opened on another device; e-mail delays |
| E-mail one-time code | Access to inbox | No | Switch to e-mail, type code | Code phishing, e-mail delays |
| SMS code | Access to phone number | No | Wait for SMS, type code | SIM swap, SMS cost, delivery |
| Passkey | Possession of a device key + local unlock | Yes | Face or fingerprint prompt | Device loss without a synced backup |
#Magic links
The user enters their e-mail. The server sends a link with a single-use token. Clicking the link signs them in.
Strengths:
- Very simple for users who are already in their inbox.
- No new concepts to learn.
Weaknesses:
- Device mismatch. The user asks for a link on their laptop and opens it on their phone. Now the phone is signed in, not the laptop. Mobile apps also need deep links configured so the link opens the app.
- E-mail scanners. Some corporate security tools open links in e-mails to check them, which can consume a single-use link before the user clicks it.
- Deliverability. Spam folders and delays break the flow. Use a reputable sending domain.
With the StitchDB SDK:
import { createClient } from 'stitchdb-js'
const stitchdb = createClient('https://<project-ref>.p.stitchdb.com', anonKey)
await stitchdb.auth.signInWithOtp({
email: '[email protected]',
options: {
emailRedirectTo: 'https://example.com/welcome',
shouldCreateUser: true,
},
})The link signs the user in and redirects to emailRedirectTo, which must be on the project's list of redirect URLs. With shouldCreateUser: false, only existing users get a link.
#One-time codes by e-mail
Same idea, but instead of a link the e-mail contains a short code that the user types into the app. This solves the device-mismatch problem: the code works wherever the user typed their e-mail.
On StitchDB, put {{ .Token }} in the Magic link e-mail template and verify the code:
await stitchdb.auth.signInWithOtp({ email: '[email protected]' })
// later, with the code the user typed
const { data, error } = await stitchdb.auth.verifyOtp({
email: '[email protected]',
token: '123456',
type: 'email',
})Codes have 6 digits by default (configurable up to 10) and expire after one hour by default. Both are set in Authentication › Sign-in methods. See Passwordless sign-in.
A good pattern is to include both a link and a code in the same e-mail. Users on the same device click; users on another device type.
#SMS codes
SMS works for users who do not use e-mail much, and in markets where phone numbers are the primary identity. It is also the weakest option on this list:
- SIM swap attacks let an attacker take over a phone number.
- Cost. Every SMS costs money, and SMS pumping fraud (bots requesting codes to premium numbers) can run up large bills.
- Delivery varies by country and carrier.
If you offer SMS, rate-limit requests per IP and per number, and consider bot protection on the request form. StitchDB supports phone sign-in with your own SMS provider; see Phone sign-in. SMS limits default to 30 per hour per project, adjustable in the phone settings.
#Passkeys
Passkeys are the most secure passwordless method. They use the WebAuthn standard: the user's device creates a public/private key pair for your site. The private key never leaves the device (or the user's synced password manager). To sign in, the device signs a challenge from your server, after the user unlocks it with a face, fingerprint or device PIN.
Why passkeys matter:
- Phishing resistant. A passkey is bound to your domain (the relying party ID). A look-alike site cannot use it.
- Nothing to steal on the server. You store only public keys.
- Fast. One biometric prompt, no switching apps.
Things to plan for:
- Account recovery. Users lose devices. Synced passkeys (through iCloud Keychain, Google Password Manager and other credential managers) help, but you still need a fallback, usually e-mail.
- Enrollment. Most users will not create a passkey during sign-up. Offer it right after a successful sign-in: "Sign in faster next time with a passkey."
- Domain changes. Because passkeys are bound to a domain, changing your app's domain needs planning.
On StitchDB, turn on passkeys in Authentication › Sign-in methods › Passkeys. A passkey is bound to your Site URL's host by default; you can set your own domain and allowed origins if your app runs elsewhere. Users can have up to 10 passkeys by default. The flow uses four Auth API endpoints:
| Step | Endpoint | Who calls it |
|---|---|---|
| Add a passkey: get options | POST /auth/v1/passkeys/registration/options | Signed-in user |
| Add a passkey: verify | POST /auth/v1/passkeys/registration/verify | Signed-in user |
| Sign in: get options | POST /auth/v1/passkeys/authentication/options | Anyone, with the API key |
| Sign in: verify and get a session | POST /auth/v1/passkeys/authentication/verify | Anyone, with the API key |
Your app fetches the options, passes them to the browser's WebAuthn API (navigator.credentials.create() to register, navigator.credentials.get() to sign in), and sends the result back. A passkey sign-in is a full session, the same as a password sign-in. Users can list, rename and delete their passkeys with GET, PATCH and DELETE on /auth/v1/passkeys. See Passkeys.
#Passwordless authentication security details
- Single use and short expiry. Links and codes must work once and expire quickly. Fifteen minutes to an hour is common.
- Rate limits. Limit requests per IP and per address. On StitchDB, magic link and code requests and verifications default to 30 per 5 minutes per IP, and e-mails count toward the project's hourly e-mail limit; see Auth limits.
- No account enumeration. The response to "send me a link" should look the same whether or not the address has an account.
- Restrict redirect URLs to your own domains, so a link cannot send the user (and their session) to an attacker's page.
- Own your sending domain. Use your own SMTP server or sending domain for auth e-mails, with SPF, DKIM and DMARC set up. See Auth e-mails.
- Combine with MFA for sensitive actions. An e-mail-based sign-in is only as strong as the inbox. For admin areas or payments, add a second factor and require it in your policies (
aal2). See Multi-factor authentication.
#Do you need a sign-in UI?
Building sign-in screens, error states and e-mail templates in seven languages is more work than the auth calls themselves. StitchDB's hosted sign-in page at https://<ref>.p.stitchdb.com/login includes password, sign-up, sign-in link or code, password reset, two-step verification and your OAuth providers, with your name, logo, colors and language. The SDK uses PKCE, so tokens never appear in the URL:
const stitchdb = createClient(url, anonKey, { auth: { flowType: 'pkce' } })
await stitchdb.auth.signInWithHostedPage({ redirectTo: 'https://yourapp.com/auth/callback' })#A rollout plan
- Start with e-mail codes plus links as the default sign-in. It works everywhere.
- Keep passwords for existing users at first, and offer passwordless next to them.
- Prompt for a passkey after a successful sign-in on a supported device.
- Watch the metrics: sign-in completion rate, time to sign in, support tickets about e-mails not arriving.
- Make passkeys the first option for returning users who have one, with e-mail as the fallback.
- Add MFA for sensitive roles.
#FAQ
#Is passwordless more secure than passwords?
Passkeys are much more secure: they resist phishing and leave nothing reusable on the server. Magic links and codes remove password reuse but are only as secure as the user's inbox.
#Do magic links work in mobile apps?
Yes, with deep links configured so the link opens your app, and the deep link added to the allowed redirect URLs. One-time codes avoid the problem entirely.
#What if a user loses their passkey device?
Synced passkeys survive device loss. Otherwise, fall back to e-mail sign-in and let the user register a new passkey.
#How do JWTs fit into passwordless sign-in?
The method only changes how the user proves who they are. Afterward, they get the same access and refresh tokens. See JWT Authentication Explained.
Add magic links, codes and passkeys without building an auth server: start free on StitchDB.