DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Authentication in Modern SaaS: Passwordless & Multi-Tenant Patterns

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:

  1. Eliminates password fatigue — Your users stop managing yet another credential. They use their email or phone they already check obsessively.
  2. Reduces your attack surface — No bcrypt leaks, no replay vulnerabilities, no password reset token circulation.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)