@threshold1/auth
v0.1.20
Published
Threshold1 Passkey Authentication SDK
Maintainers
Readme
@threshold1/auth
Passkey-first authentication SDK for modern web apps. Add passkey, OTP, and magic link auth with automatic fallback — in minutes. No backend required.
npm: @threshold1/auth
Docs: threshold1-docs.vercel.app
Dashboard: threshold1.phantomclick.in
How It Works
- Sign up at threshold1.phantomclick.in
- Get an API key from the dashboard
- Install the SDK
- Call
auth.login(email)— threshold1 handles everything else
The SDK connects to our hosted production API by default. No baseUrl needed. No server to run.
Install
npm install @threshold1/authQuick Start
import { Threshold1 } from '@threshold1/auth'
// IMPORTANT: Initialize at module level — outside any component or function
// Initializing inside a component resets auth state on every render
const auth = new Threshold1({
apiKey: 'th_live_xxxxxxxxxxxxxxxx',
redirectUrl: 'https://yourapp.com',
auth: {
methods: ['passkey', 'otp', 'magic'],
passkeyNotFound: 'register', // create passkey on first login
onAfterAuth: async (user, method) => { // link to your DB after every auth
await myDB.upsert({ t1UserId: user.id, email: user.email })
}
}
})
await auth.register('[email protected]') // register new user
await auth.login('[email protected]') // login existing user
await auth.resumeSession() // call on every page loadConfiguration
const auth = new Threshold1({
apiKey: 'th_live_xxxxxxxxxxxxxxxx', // Required
redirectUrl: 'https://yourapp.com', // Required for magic link
auth: {
methods: ['passkey', 'otp', 'magic'], // Default: ['passkey', 'magic', 'otp']
onFallback: (attempted, used) => { // Called when SDK falls back
console.log(`${attempted} failed, using ${used}`)
},
onAfterAuth: async (user, method) => { // Called after every successful auth
await myDB.upsert({ t1UserId: user.id, email: user.email })
},
passkeyNotFound: 'fallback', // 'fallback' | 'register' | 'register-strict'
},
debug: false // Set true for detailed console logs
})Config options
| Option | Required | Description |
|--------|----------|-------------|
| apiKey | Yes | Your th_live_ key from the dashboard |
| redirectUrl | For magic link | URL where users land after clicking the magic link email |
| auth.methods | No | Auth method priority order. Default: ['passkey', 'magic', 'otp'] |
| auth.onFallback | No | Called when SDK falls back. Args: (attempted: string, used: string) |
| auth.onAfterAuth | No | Called after every successful auth. Args: (user: UserProfile, method: AuthMethod). Use to link threshold1 user to your database. |
| auth.passkeyNotFound | No | 'fallback' (default) — go to OTP/magic. 'register' — create passkey on the spot, fall back if fails. 'register-strict' — create passkey or throw error. |
| debug | No | Logs detailed auth flow to console. Development only. |
Methods
Core
await auth.register(email) // Register new user — tries methods in order with fallback
await auth.login(email) // Login existing user — tries methods in order with fallback
await auth.resumeSession() // Call on every page load — handles magic link token in URL
await auth.getUser() // Returns { id, email } — throws if not authenticated
await auth.getSession() // Returns { userId, issuedAt, expiresAt }
await auth.logout() // Invalidates session server-side, clears JWT from memory
auth.isAuthenticated // Boolean — true if JWT in memoryIdentity Bridge (externalUserId)
Connect threshold1 passkeys to your existing user IDs. Use this when you already have users in your own database.
// Register with your own user ID
await auth.register({ email: '[email protected]', externalUserId: 'your_user_123' })
// onAfterAuth receives your own user ID back
onAfterAuth: async (user, method) => {
// user.externalUserId — your own user ID (null if not set)
// user.id — threshold1 internal UUID
const myUser = await myDB.findById(user.externalUserId)
}Passkey Management
// Enroll a passkey for an already-authenticated user
// Call after login() via OTP or magic link to add passkey to their account
const passkey = await auth.addPasskey({ externalUserId: 'your_user_123', email: '[email protected]' })
// Returns { credentialId, createdAt, deviceHint }
// List all registered passkeys for current user
const passkeys = await auth.listPasskeys()
// Returns [{ credentialId, createdAt }]
// Remove a passkey by credential ID
await auth.removePasskey(passkey.credentialId)Passkey-first login (no email)
// Show passkey picker without asking for email first
try {
await auth.login() // no args — browser shows passkey picker
} catch (err) {
// No passkey found or user cancelled — ask for email and fall back
showEmailInput()
}OTP with custom UI
// Send OTP to email
await auth.sendOtp(email)
// User enters code in your own input field
const user = await auth.verifyOtp(email, code)
// Returns { id, email } — JWT stored automaticallyIndividual methods (no fallback)
await auth.registerPasskey(email) // Passkey only
await auth.loginWithPasskey(email) // Passkey only — respects passkeyNotFound config
await auth.loginWithMagic(email) // Magic link only
await auth.registerWithMagic(email) // Magic link only
// Deprecated — use sendOtp + verifyOtp instead
await auth.loginWithOtp(email) // Uses window.prompt() — blocked in iframes
await auth.registerWithOtp(email) // Uses window.prompt() — blocked in iframesHooks
onAfterAuth
Fires after every successful authentication — register(), login(), resumeSession(). Use it to link threshold1 users to your own database.
onAfterAuth: async (user, method) => {
// user.id — stable threshold1 user ID
// user.email — user's email address
// user.externalUserId — your own user ID (null if not set)
// method — 'passkey' | 'otp' | 'magic'
await myDB.users.upsert({
t1UserId: user.id,
email: user.email,
lastLoginMethod: method,
lastSeenAt: new Date(),
}, { onConflict: 'email' })
}Note: If calling
verifyOtp()directly (not throughlogin()/register()),onAfterAuthdoes not fire automatically. Handle post-auth logic yourself in that case.
passkeyNotFound
Controls what happens when login() is called but the user has no passkey saved:
// 'fallback' (default) — go to next method in chain (OTP/magic)
passkeyNotFound: 'fallback'
// 'register' — try to create passkey on the spot, fall back if fails
passkeyNotFound: 'register'
// 'register-strict' — create passkey or throw error (no fallback)
passkeyNotFound: 'register-strict'With 'register', users never need to explicitly sign up first — login handles both new and returning users automatically.
Magic Link Callback
// Call on every page load — required for magic link to work
useEffect(() => {
auth.resumeSession()
.then(user => { if (user) setCurrentUser(user) })
.catch(err => {
// Token expired or already used
setError('Magic link expired. Please request a new one.')
})
}, [])Integration Patterns
Add passkey to existing app
threshold1 is headless — no UI components. Add a passkey button alongside your existing auth:
// Your existing Google/password buttons stay unchanged
// Add threshold1 for passkey/OTP/magic
async function handlePasskeyLogin() {
await auth.login(email)
const t1User = await auth.getUser()
const myUser = await myDB.findByEmail(t1User.email)
setCurrentUser(myUser)
}Session persistence across page refresh
JWT is in memory only. Use onAfterAuth to save session to localStorage:
onAfterAuth: async (user, method) => {
const session = await auth.getSession()
localStorage.setItem('t1_session', JSON.stringify({
userId: user.id, email: user.email, expiresAt: session.expiresAt
}))
}
// On page load — restore saved session
const saved = localStorage.getItem('t1_session')
if (saved) {
const s = JSON.parse(saved)
if (new Date(s.expiresAt) > new Date()) {
setUser({ id: s.userId, email: s.email })
} else {
localStorage.removeItem('t1_session')
}
}User management
threshold1 does not provide a user list API. You own your users — build your own users table using webhooks:
// Webhook handler — your server
if (event.event === 'user.registered') {
await myDB.users.create({
t1UserId: event.data.user_id, // use as foreign key
email: event.data.email,
})
}Passkey Setup
Production
- Go to dashboard → Authentication → Passkey RP Domain
- Add your production domain under Production Domain
- Add your production URL to Passkey Origins
- Deploy to HTTPS — done
Local Development with ngrok
Passkey requires HTTPS. ngrok creates an HTTPS tunnel to your local app.
brew install ngrok
ngrok http 3001
# Output: https://abc123.ngrok-free.dev -> http://localhost:3001Setup (do once per ngrok URL):
- Add ngrok URL to Passkey Origins in dashboard
- Set ngrok URL as Test Domain in dashboard → Authentication → Passkey RP Domain
- Update
redirectUrlin SDK config to ngrok URL - Open your app via the ngrok URL — not localhost
Fix React hydration through ngrok (Next.js only):
Next.js HMR uses WebSocket which ngrok blocks, preventing button clicks from working. Add to next.config.ts:
const nextConfig: NextConfig = {
allowedDevOrigins: ['abc123.ngrok-free.dev'], // no https://
}When ngrok URL changes (free tier changes on every restart):
- Update
allowedDevOriginsinnext.config.ts - Update Passkey Origins in dashboard
- Update Test Domain in dashboard
- Update
redirectUrlin SDK config
OTP and magic link work on http://localhost without ngrok.
Error Handling
import { Threshold1ApiError } from '@threshold1/auth'
try {
await auth.login(email)
} catch (err) {
if (err instanceof Threshold1ApiError) {
console.error(err.message) // Human-readable
console.error(err.status) // HTTP status
console.error(err.code) // Machine-readable — see table below
}
}Error codes
| Code | Meaning | Action |
|------|---------|--------|
| MISSING_API_KEY | No Authorization header | Check SDK initialization |
| INVALID_FORMAT | API key format wrong | Check key starts with th_live_ |
| API_KEY_NOT_FOUND | Key prefix not found | Check key is correct |
| API_KEY_REVOKED | Key was revoked | Create new key in dashboard |
| HASH_MISMATCH | Key prefix found but hash mismatch | Key may be corrupted — create new |
| RATE_LIMIT_EXCEEDED | Too many requests or OTP sends | Slow down or wait |
| MAU_LIMIT_EXCEEDED | Monthly user limit reached | Upgrade plan in dashboard |
| INVALID_INPUT | Bad email or missing field | Check email format |
| INVALID_CODE | Wrong or expired OTP | Request new OTP |
| INVALID_TOKEN | Magic link expired or used | Request new magic link |
| INVALID_SESSION | JWT expired or session deleted | User must log in again |
| ORIGIN_NOT_ALLOWED | Domain not in Allowed Origins | Add domain in dashboard |
| EMAIL_CONFLICT | Email already in use | Use different email |
| INTERNAL_ERROR | Server error | Check dashboard status |
React Example
'use client'
import { useState, useEffect } from 'react'
import { Threshold1 } from '@threshold1/auth'
// Module level — NEVER inside the component function
const auth = new Threshold1({
apiKey: process.env.NEXT_PUBLIC_T1_API_KEY!,
redirectUrl: process.env.NEXT_PUBLIC_T1_REDIRECT_URL!,
auth: {
methods: ['passkey', 'otp', 'magic'],
passkeyNotFound: 'register',
onAfterAuth: async (user) => {
await myDB.upsert({ t1UserId: user.id, email: user.email })
}
}
})
export default function AuthPage() {
const [email, setEmail] = useState('')
const [user, setUser] = useState(null)
useEffect(() => {
auth.resumeSession()
.then(u => { if (u) setUser(u) })
.catch(() => {})
}, [])
if (user) return (
<div>
<p>Welcome, {user.email}</p>
<button onClick={async () => { await auth.logout(); setUser(null) }}>
Logout
</button>
</div>
)
return (
<div>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<button onClick={async () => { await auth.login(email); setUser(await auth.getUser()) }}>
Sign in
</button>
</div>
)
}Important Notes
Initialize at module level.
Always put const auth = new Threshold1({...}) outside any component function. Initializing inside a component resets auth state on every render.
Passkeys are domain-bound.
A passkey registered on yourapp.com cannot be used on staging.yourapp.com. Users need separate passkeys per domain. With passkeyNotFound: 'register', this happens automatically on first login from each domain.
Session tokens are in-memory only.
JWT is lost on page refresh. For persistent sessions, use onAfterAuth to save to localStorage and restore on page load.
API key is safe client-side.
Can only create auth sessions for your users. Cannot access other companies' data. Rotate from dashboard if compromised.
user.registered fires from login() too.
When login() is called with a new email, threshold1 creates the user and fires user.registered. Always use user.registered webhook to provision users — never assume it only comes from register().
Browser support.
Passkey: Chrome 108+, Safari 16+, Firefox 122+, Edge 108+. OTP and magic link work in all browsers.
Version
Current version: 0.1.15.
License
MIT
