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
User Enters Email
User provides their email address to sign in.
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)
Email Sent
Email is sent containing:
Magic link with full verification code
6-digit OTP for manual entry
Verification
User either:
Clicks the magic link (automatic)
Enters the 6-digit code (manual)
Authentication
Stack Auth verifies the code and signs in the user.
Configuration
Enable OTP Authentication
In the Stack Auth dashboard, navigate to Authentication > OTP and enable one-time password authentication.
Configure Email Template
Customize the magic link email template with your branding and messaging.
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
Magic Link (Automatic)
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:
Has OTP enabled : Signs in normally
No OTP enabled : OTP auth method is automatically added
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
OTP Not Enabled
Invalid Code
Email Already Exists
Sign Up Disabled
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).
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
Clear instructions - Tell users to check their email and look for both the link and code
Resend option - Allow users to request a new code if they didn’t receive it
Code expiration - Clearly communicate when codes expire
Fallback to password - Offer password sign-in as an alternative
Loading states - Show loading indicators during code sending and verification
Error messages - Provide helpful error messages for expired or invalid codes
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