Skip to main content
Magic link authentication (also called OTP or one-time password) allows users to sign in without a password. Stack Auth sends a verification code via email that can be used as a clickable link or entered manually.

Features

  • Passwordless authentication
  • 6-digit OTP codes
  • Magic link support (clickable email links)
  • Automatic account creation for new users
  • Automatic account linking for verified emails
  • Email verification built-in

How It Works

1

User Enters Email

User provides their email address to sign in.
2

Code Generation

Stack Auth generates a 45-character verification code:
  • First 6 characters: OTP code (shown to user)
  • Remaining 39 characters: Nonce (used for verification)
3

Email Sent

Email is sent containing:
  • Magic link with full verification code
  • 6-digit OTP for manual entry
4

Verification

User either:
  • Clicks the magic link (automatic)
  • Enters the 6-digit code (manual)
5

Authentication

Stack Auth verifies the code and signs in the user.

Configuration

1

Enable OTP Authentication

In the Stack Auth dashboard, navigate to Authentication > OTP and enable one-time password authentication.
2

Configure Email Template

Customize the magic link email template with your branding and messaging.
3

Set Callback URL

Configure the allowed callback URLs where users will be redirected after clicking the magic link.

Send Sign-In Code

Client-Side Implementation

import { useStackApp } from "@stackframe/stack";
import { useState } from "react";

function MagicLinkSignIn() {
  const app = useStackApp();
  const [email, setEmail] = useState("");
  const [nonce, setNonce] = useState("");
  const [codeSent, setCodeSent] = useState(false);

  const sendCode = async () => {
    const result = await app.sendMagicLinkEmail({
      email,
      callbackUrl: "https://yourapp.com/auth/verify"
    });
    
    setNonce(result.nonce);
    setCodeSent(true);
  };

  return (
    <div>
      {!codeSent ? (
        <>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="Enter your email"
          />
          <button onClick={sendCode}>Send Magic Link</button>
        </>
      ) : (
        <p>Check your email for a magic link and 6-digit code!</p>
      )}
    </div>
  );
}

API Request

POST /api/v1/auth/otp/send-sign-in-code
Content-Type: application/json
x-stack-publishable-client-key: your_publishable_key

{
  "email": "[email protected]",
  "callback_url": "https://yourapp.com/auth/verify"
}

API Response

{
  "nonce": "u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2"
}
The nonce must be stored temporarily and combined with the 6-digit OTP for verification.

Verify Sign-In Code

When users click the magic link in their email, they’re redirected to your callback URL with the full code:
https://yourapp.com/auth/verify?code=ABC123u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2
Handle the callback:
import { useStackApp } from "@stackframe/stack";
import { useSearchParams } from "next/navigation";

function VerifyMagicLink() {
  const app = useStackApp();
  const searchParams = useSearchParams();
  const code = searchParams.get("code");

  useEffect(() => {
    if (code) {
      verifyCode();
    }
  }, [code]);

  const verifyCode = async () => {
    try {
      await app.signInWithMagicLink({ code });
      // User is now signed in
      router.push("/dashboard");
    } catch (error) {
      console.error("Invalid or expired code", error);
    }
  };

  return <div>Verifying...</div>;
}

Manual OTP Entry

Allow users to manually enter the 6-digit code:
import { useStackApp } from "@stackframe/stack";
import { useState } from "react";

function OTPVerification({ nonce }: { nonce: string }) {
  const app = useStackApp();
  const [otp, setOtp] = useState("");
  const [error, setError] = useState("");

  const verifyOTP = async () => {
    try {
      // Combine OTP with nonce to form the full verification code
      const code = otp + nonce;
      
      await app.signInWithMagicLink({ code });
      // User is now signed in
    } catch (err: any) {
      setError("Invalid or expired code");
    }
  };

  return (
    <div>
      <input
        type="text"
        value={otp}
        onChange={(e) => setOtp(e.target.value)}
        placeholder="Enter 6-digit code"
        maxLength={6}
      />
      {error && <p>{error}</p>}
      <button onClick={verifyOTP}>Verify</button>
    </div>
  );
}

API Request

POST /api/v1/auth/otp/sign-in
Content-Type: application/json
x-stack-publishable-client-key: your_publishable_key

{
  "code": "ABC123u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2"
}

API Response

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user_id": "user_1234567890",
  "is_new_user": false
}

Complete Flow Example

import { useStackApp } from "@stackframe/stack";
import { useState } from "react";
import { useRouter } from "next/navigation";

function MagicLinkAuth() {
  const app = useStackApp();
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [nonce, setNonce] = useState("");
  const [otp, setOtp] = useState("");
  const [step, setStep] = useState<"email" | "verify">("email");
  const [error, setError] = useState("");

  const sendCode = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");
    
    try {
      const result = await app.sendMagicLinkEmail({
        email,
        callbackUrl: window.location.origin + "/auth/verify"
      });
      
      setNonce(result.nonce);
      setStep("verify");
    } catch (err: any) {
      setError(err.message);
    }
  };

  const verifyOTP = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");
    
    try {
      const code = otp + nonce;
      await app.signInWithMagicLink({ code });
      router.push("/dashboard");
    } catch (err: any) {
      setError("Invalid or expired code");
    }
  };

  if (step === "email") {
    return (
      <form onSubmit={sendCode}>
        <h2>Sign in with Magic Link</h2>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Enter your email"
          required
        />
        {error && <p className="error">{error}</p>}
        <button type="submit">Send Magic Link</button>
      </form>
    );
  }

  return (
    <form onSubmit={verifyOTP}>
      <h2>Enter Verification Code</h2>
      <p>Check {email} for a 6-digit code or click the magic link.</p>
      <input
        type="text"
        value={otp}
        onChange={(e) => setOtp(e.target.value)}
        placeholder="000000"
        maxLength={6}
        required
      />
      {error && <p className="error">{error}</p>}
      <button type="submit">Verify</button>
      <button type="button" onClick={() => setStep("email")}>
        Use a different email
      </button>
    </form>
  );
}

Account Behavior

New Users

When a user signs in with an email that doesn’t exist:
// From verification-code-handler.tsx
if (!user) {
  user = await createOrUpgradeAnonymousUserWithRules(
    tenancy,
    currentUser ?? null,
    {
      primary_email: email,
      primary_email_verified: true,  // OTP verifies the email
      primary_email_auth_enabled: true,
      otp_auth_enabled: true,
    },
    [],
    { authMethod: 'otp' }
  );
  isNewUser = true;
}

Existing Users

For users with verified emails:
  1. Has OTP enabled: Signs in normally
  2. No OTP enabled: OTP auth method is automatically added
  3. Unverified email: Error thrown to prevent account takeover
if (contactChannel.isVerified) {
  if (!otpAuthMethod) {
    // Automatically add OTP auth method
    await prisma.authMethod.create({
      data: {
        projectUserId: contactChannel.projectUser.projectUserId,
        tenancyId: tenancy.id,
        otpAuthMethod: {
          create: {
            projectUserId: contactChannel.projectUser.projectUserId,
          }
        }
      },
    });
  }
}

Email Template

The magic link email includes both the clickable link and OTP:
// From send code implementation
await sendEmailFromDefaultTemplate({
  tenancy,
  email: method.email,
  user: null,
  templateType: "magic_link",
  extraVariables: {
    magicLink: codeObj.link.toString(),
    otp: codeObj.code.slice(0, 6).toUpperCase(),  // First 6 chars
  },
});
Customize the template in your Stack Auth dashboard to match your brand.

Check Code Without Using It

Validate a code without consuming it:
POST /api/v1/auth/otp/sign-in/check-code
Content-Type: application/json
x-stack-publishable-client-key: your_publishable_key

{
  "code": "ABC123u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2"
}
This is useful for validating codes in the UI before actually signing in.

Multi-Factor Authentication

When MFA is enabled, OTP sign-in requires an additional TOTP verification:
try {
  await app.signInWithMagicLink({ code });
} catch (error) {
  if (error.code === "MULTI_FACTOR_AUTHENTICATION_REQUIRED") {
    // Redirect to MFA verification
    const attemptCode = error.attemptCode;
    router.push(`/mfa?code=${attemptCode}`);
  }
}
See the MFA documentation for more details.

Error Handling

try {
  await app.sendMagicLinkEmail({ email, callbackUrl });
} catch (error) {
  if (error.code === "OTP_SIGN_IN_NOT_ENABLED") {
    console.log("Magic link authentication is disabled");
  }
}

Security Considerations

Code Expiration

Verification codes expire after a configured time period (typically 10-15 minutes).

Code Format

The 45-character code structure:
  • Characters 1-6: OTP displayed to user (uppercase letters/numbers)
  • Characters 7-45: Nonce for server-side verification
const fullCode = "ABC123" + "u3h6gn4w24pqc8ya679inrhjwh1rybth6a7thurqhnpf2";
//              └─OTP─┘   └───────────── Nonce ─────────────────┘

Email Verification

Magic link authentication inherently verifies email ownership since the user must access their email to receive the code.

Rate Limiting

Implement rate limiting to prevent abuse:
  • Limit code requests per email address
  • Limit verification attempts per code
  • Implement exponential backoff for failed attempts

Best Practices

  1. Clear instructions - Tell users to check their email and look for both the link and code
  2. Resend option - Allow users to request a new code if they didn’t receive it
  3. Code expiration - Clearly communicate when codes expire
  4. Fallback to password - Offer password sign-in as an alternative
  5. Loading states - Show loading indicators during code sending and verification
  6. Error messages - Provide helpful error messages for expired or invalid codes
  7. Auto-submit - Consider auto-submitting when user enters all 6 digits

API Reference

Key endpoints for magic link/OTP authentication:
  • POST /api/v1/auth/otp/send-sign-in-code - Send verification code via email
  • POST /api/v1/auth/otp/sign-in - Sign in with verification code
  • POST /api/v1/auth/otp/sign-in/check-code - Validate code without consuming it

Build docs developers (and LLMs) love