I've watched too many SaaS founders bolt on authentication as an afterthought and regret it within 6 months. By then, they're shipping feature work while wrestling with token expiration bugs, multi-tenant data leaks, and angry customers locked out at 2 AM. I built CitizenApp on a different principle: authentication is infrastructure, not scaffolding. And passwordless + AI-driven verification is the path to sane, scalable auth that actually serves your users instead of becoming your primary support burden.
Why Passwordless Isn't Just Hype
Passwords are a liability you don't need. That's not idealism—it's math. Every password you store is a vector; every rotation cycle is operational overhead. But passwordless adoption fails when it's treated as a UX gimmick ("click the magic link!") instead of a security architecture.
I prefer passwordless-first because:
- Eliminates password fatigue — Your users stop managing yet another credential. They use their email or phone they already check obsessively.
- Reduces your attack surface — No bcrypt leaks, no replay vulnerabilities, no password reset token circulation.
- Scales auth complexity gracefully — When you add multi-tenancy, passwordless becomes a force multiplier for session isolation.
The catch: passwordless only works if you nail the verification layer. That's where most implementations fail. They generate a token, email a link, and call it done. But in a multi-tenant SaaS, you need context-aware verification—knowing not just that someone verified their email, but which tenant they're logging into and whether they have permission.
The Multi-Tenant Verification Problem
Here's what I missed the first time: naive passwordless implementations create a single verification flow that doesn't account for tenant context. User clicks the link, gets verified, and then you have to figure out which workspace they belong to. That's a race condition waiting to happen.
The pattern I now use embeds tenant context into the verification token itself:
// FastAPI backend — generating a passwordless token with tenant context
from fastapi import FastAPI, HTTPException
from sqlalchemy import select
from datetime import datetime, timedelta
import jwt
import anthropic
app = FastAPI()
async def generate_passwordless_token(
email: str,
tenant_id: str,
secret_key: str
) -> str:
"""
Generate a JWT that encodes both identity and tenant context.
This prevents cross-tenant verification hijacking.
"""
payload = {
"sub": email,
"tenant_id": tenant_id,
"type": "passwordless_verify",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(minutes=15),
}
token = jwt.encode(payload, secret_key, algorithm="HS256")
return token
async def send_passwordless_email_with_claude(
email: str,
tenant_id: str,
tenant_name: str,
verification_url: str,
):
"""
Use Claude to generate contextual, personalized login emails.
This sounds fancy but solves a real problem: generic emails get flagged as phishing.
"""
client = anthropic.Anthropic()
prompt = f"""Generate a professional passwordless login email for a SaaS application.
Email: {email}
Workspace: {tenant_name}
Login link: {verification_url}
Requirements:
- Be concise (2-3 sentences max)
- Mention the workspace name to confirm they're logging into the right place
- Include clear CTA text
- Warn that the link expires in 15 minutes
- Professional but friendly tone
Return ONLY the email body, no subject line."""
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=300,
messages=[
{"role": "user", "content": prompt}
]
)
email_body = message.content[0].text
# Send via your email provider (Resend, SendGrid, etc.)
print(f"Email body:\n{email_body}")
return email_body
Why use Claude here? Because phishing filters treat templated emails like spammers. A slightly varied, context-aware email body improves deliverability and confirms to the user they're logging into the right workspace. It's a win on security theater that's actually theater worth watching.
Session Isolation & RBAC Layer
Once someone verifies, you need bulletproof session isolation. This is where I've seen the most damage: a token issued for one tenant accidentally grants access to another's data.
// React 19 component with multi-tenant context isolation
import { useEffect, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
interface AuthSession {
access_token: string;
tenant_id: string;
role: 'admin' | 'member' | 'viewer';
expires_at: number;
}
export function LoginPage({ defaultTenantId }: { defaultTenantId?: string }) {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [step, setStep] = useState<'email' | 'verify'>('email');
const { setSession } = useAuth();
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/auth/passwordless/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
tenant_id: defaultTenantId,
}),
});
if (!response.ok) throw new Error('Failed to send verification email');
setStep('verify');
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleVerificationToken = async (token: string) => {
try {
const response = await fetch('/api/auth/passwordless/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) throw new Error('Verification failed');
const session: AuthSession = await response.json();
// Store session WITH tenant isolation
// Critical: tenant_id is part of the session, not derived from URL
setSession(session);
// Redirect to tenant-specific dashboard
window.location.href = `/workspace/${session.tenant_id}/dashboard`;
} catch (error) {
console.error(error);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-900">
{step === 'email' ? (
<form onSubmit={handleEmailSubmit} className="w-96 space-y-4">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded text-white"
required
/>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded"
>
{loading ? 'Sending...' : 'Send Login Link'}
</button>
</form>
) : (
<div className="text-center space-y-2">
<p className="text-gray-300">Check your email for a login link</p>
<p className="text-gray-500 text-sm">Link expires in 15 minutes</p>
</div>
)}
</div>
);
}
On the backend, validate that the token's tenant_id matches the request context:
python
# FastAPI — verify token and enforce tenant isolation
from fastapi import Depends, HTTPException, status
async def verify_passwordless_token(
token: str,
expected_tenant_id: str,
secret_key: str,
) -> dict:
"""
Decode and validate passwordless token.
CRITICAL: Verify tenant_id matches expected value to prevent cross-tenant access.
"""
try:
payload = jwt.decode(token, secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# GOTCHA: This is where multi-tenant bugs live
if payload.get("tenant_id") != expected_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token not valid for this workspace"
)
if payload.get("type") != "passwordless_verify":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
return payload
@app.post("/api/auth/passwordless/verify")
async def verify_login(request: PasswordlessVerifyRequest, db: AsyncSession = Depends(get_db)):
payload = await verify_passwordless_token(
token=request.token,
expected_tenant_id=request.tenant_id,
secret_key=settings.SECRET_KEY,
)
email = payload["sub"]
user = await db.execute(
select(User).where(User.email == email)
)
user = user.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Create session token
Top comments (0)