Supabase returns "otp_expired: OTP has expired" when you attempt to verify a one-time password (OTP) that has exceeded its expiration window. By default, OTP codes are valid for 1 hour. The fix is to request a new OTP, check your configuration for timeout settings, or verify you are using the correct OTP type parameter in your authentication call.
One-time passwords (OTPs) in Supabase are time-limited security tokens that expire after a configured duration. By default, OTP codes are valid for 1 hour (3600 seconds) for email-based authentication and 60 seconds for SMS-based codes. Once this expiration window passes, the token becomes invalid and cannot be used for verification. When you call `verifyOtp()` with an expired code, Supabase checks the token timestamp and rejects the verification request with "otp_expired: OTP has expired". This is a security measure to prevent attackers from using intercepted or brute-forced tokens indefinitely. The error can occur in these contexts: - User received an OTP but did not verify it within the 1-hour window - Email provider security features (Safe Links, URL rewriting) pre-fetched the link, consuming the token before the user could use it - Password recovery link was clicked too late after the email was sent - Wrong OTP type parameter was used in the `verifyOtp()` call - The OTP was already used once (single-use tokens cannot be reused)
If the OTP has expired, the user must request a new one. Do not attempt to reuse the expired token.
For email-based authentication:
const { error } = await supabase.auth.signInWithOtp({
email: '[email protected]',
options: {
shouldCreateUser: true,
},
})
if (error) {
console.error('Failed to send new OTP:', error)
} else {
console.log('New OTP sent to email')
}For SMS-based OTP:
const { error } = await supabase.auth.signInWithOtp({
phone: '+1234567890',
})
if (error) {
console.error('Failed to send OTP via SMS:', error)
} else {
console.log('OTP sent to phone')
}The user will receive a new OTP code valid for another 1 hour.
OTP codes expire after a fixed duration (default 1 hour for email). Instruct users to verify as soon as they receive the code:
const { data, error } = await supabase.auth.verifyOtp({
email: '[email protected]',
token: 'OTP_CODE_FROM_EMAIL',
type: 'email', // Use the correct type: 'email', 'sms', 'recovery', 'invite', 'email_change'
})
if (error) {
if (error.message.includes('otp_expired')) {
console.error('OTP has expired. Request a new one.')
} else {
console.error('Verification failed:', error)
}
} else {
console.log('Verification successful, user session:', data.session)
}Make sure the type parameter matches how the OTP was originally sent. Using the wrong type will also result in verification failure.
The most common cause of false "otp_expired" errors is using the wrong type parameter in verifyOtp(). The type must match how the OTP was sent:
| Type | Usage |
|------|-------|
| 'email' | OTP sent via email (passwordless login) |
| 'sms' | OTP sent via SMS (phone login) |
| 'recovery' | Password reset link |
| 'invite' | Account invitation link |
| 'email_change' | Email change confirmation |
Example: Password Reset
// Step 1: User requests password reset
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
'[email protected]'
)
// Step 2: User receives recovery link and clicks it
// Step 3: Extract token from URL or prompt user to enter code
const { data, error } = await supabase.auth.verifyOtp({
email: '[email protected]',
token: 'TOKEN_FROM_RECOVERY_LINK',
type: 'recovery', // MUST be 'recovery', not 'email'
})
if (error && error.message.includes('otp_expired')) {
// Call resetPasswordForEmail() again to send a fresh recovery link
}If you see "otp_expired" with a recovery type, the user must request a new password reset email.
If users consistently fail to verify within 1 hour, you can increase the OTP expiration window in your Supabase dashboard (maximum allowed is 86400 seconds / 24 hours):
1. Go to Authentication > Providers
2. For Email provider: Click the settings and find Email OTP Expiration (default: 3600 seconds / 1 hour)
3. For SMS provider: Find SMS OTP Expiration (default: 60 seconds)
4. Increase the timeout value (in seconds) to allow more time
5. Click Save
Example settings:
- Email OTP: 86400 seconds (24 hours) for maximum user convenience
- SMS OTP: 300 seconds (5 minutes) for higher security
Note: Longer expiration windows increase the risk of token interception or brute-force attacks. Use the shortest expiration that is practical for your users.
Email providers like Microsoft Defender for Office 365 automatically scan URLs in emails, which can consume the OTP token before the user clicks the link. If this is happening:
1. Check the Supabase Auth Logs in your dashboard (Authentication > Logs) to see if the token was used multiple times or immediately after sending
2. If email scanning is confirmed, ask the user to:
- Disable or whitelist Supabase in their email provider's URL rewriting settings
- Or, provide a "Resend" button on the password reset page
As a workaround, consider using a custom email template that includes both:
- A clickable link for quick access
- A separate code/token that the user can manually enter on your website
Example email template:
<p>Your reset code is: <strong>{{ .Token }}</strong></p>
<p>Or click here: <a href="{{ .ConfirmationURL }}">Reset Password</a></p>This way, if Safe Links consumes the clickable link, the user can still manually enter the code.
If using email links that redirect to your app, verify that your Site URL and Redirect URLs are correctly configured in your Supabase project settings:
1. Go to Authentication > URL Configuration
2. Verify Site URL matches your production domain exactly (e.g., https://yourapp.com)
3. In Redirect URLs, add all allowed post-login/post-verification redirects:
- https://yourapp.com/auth/callback
- https://yourapp.com/password-reset
- https://yourapp.com (for the homepage)
When the OTP link is clicked, Supabase verifies the redirect destination. If the Site URL or redirect does not match the email link's origin, the verification may fail or timeout.
For development, ensure you also add:
- http://localhost:3000
- http://127.0.0.1:3000
When using reauthenticate() to verify the user's identity before sensitive operations (like password change), use the nonce pattern instead of verifying OTP directly:
// Step 1: User requests reauthentication (sends OTP)
const { error: reauthError } = await supabase.auth.reauthenticate()
// Step 2: User receives OTP in email
// Step 3: User enters OTP, then update password with nonce
const { data, error } = await supabase.auth.updateUser({
password: 'newPassword123',
nonce: 'OTP_CODE_FROM_EMAIL', // Use nonce for reauthentication
})
if (error) {
if (error.message.includes('otp_expired')) {
console.error('OTP expired, request reauthentication again')
await supabase.auth.reauthenticate()
} else {
console.error('Update failed:', error)
}
} else {
console.log('Password updated successfully')
}The nonce parameter is specifically designed for sensitive operations and handles OTP validation internally.
### OTP Token Architecture in Supabase
Supabase uses GoTrue as its authentication backend. OTP tokens are single-use, time-based codes generated with a timestamp. Once the server verifies the code, the timestamp is checked against the current time. If the difference exceeds the configured expiration (default 3600 seconds for email), the server rejects the verification.
### Email Link vs. OTP Code
Supabase supports two passwordless methods:
1. Email Links - A clickable link with an embedded token (contains all data needed for verification)
2. OTP Codes - A 6-digit code sent in the email that the user enters manually
Email links can be problematic with email scanning. Using OTP codes provides better protection because the code is separate from the URL and won't be consumed by Safe Links scanning.
To use OTP codes instead of links, you can customize your email template to include {{ .Token }} and create a custom page where users enter the code manually, then verify with verifyOtp().
### Rate Limiting on OTP Requests
By default, users can only request an OTP once every 60 seconds. If a user clicks "resend" too quickly, they will get a "rate limit exceeded" error instead of a new OTP. Educate users to wait at least 1 minute between resend attempts.
### Security Considerations
The longer OTP tokens are valid, the greater the window for:
- Brute-force attacks (attackers try every code combination)
- Phishing and social engineering (more time to trick users into sharing the code)
- Interception (longer validity means stolen tokens are useful for longer)
Balance usability (users need time to receive and verify) with security (minimize exposure window). For most applications, 1 hour for email and 5-10 minutes for SMS is reasonable.
### Testing OTP Flows Locally
When running supabase start, emails are not delivered to a real inbox. To test OTP flows:
1. Check the Supabase logs with supabase logs --local to see the OTP code
2. Or configure a local email service like Mailhog
3. Or disable email confirmation for development
email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth
reauthentication_needed: Reauthentication required for security-sensitive actions
Reauthentication required for security-sensitive actions
no_authorization: No authorization header was provided
How to fix "no authorization header was provided" in Supabase
bad_oauth_state: OAuth state parameter is missing or invalid
How to fix 'bad_oauth_state: OAuth state parameter missing' in Supabase
mfa_factor_not_found: MFA factor could not be found
How to fix "mfa_factor_not_found: MFA factor could not be found" in Supabase