OTP Expiration Time Mismatch with Local Timezone #592

Closed
opened 2026-03-13 07:55:30 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @ingokpp on GitHub (Jan 26, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Description

When using the Email OTP plugin, there appears to be a timezone mismatch between the stored expiration time and local time. The OTP expiration timestamp in the database is stored in UTC but doesn't seem to be properly converted when compared with the local time.

Steps to Reproduce

  1. Configure Email OTP plugin with expiresIn: 300 (5 minutes)
  2. Request an OTP at local time 12:35 CET
  3. Check the database - expiration timestamp shows as 11:39 UTC
  4. The difference between expiration and creation time

Current vs. Expected behavior

Current Behavior

The OTP expires earlier than expected because the expiration time seems to be handled in UTC without proper timezone conversion. For example:

  • Local time: 12:35 CET
  • Expiration timestamp in DB: 11:39 UTC
  • Expected expiration: Local time + 5 minutes
  • Actual expiration: UTC time + 5 minutes

Expected Behavior

The OTP should expire exactly 5 minutes after creation, regardless of the timezone setting. The expiration check should properly account for the timezone difference between UTC (stored in DB) and the local timezone.

Additional Context

  • PostgreSQL is correctly configured with timezone settings
  • Node.js application correctly identifies timezone as Europe/Berlin
  • The issue appears to be in how the expiration timestamp is handled when validating OTPs

What version of Better Auth are you using?

^1.1.14

Provide environment information

- Database: PostgreSQL 16
- Database timezone: Europe/Berlin (confirmed via `SHOW timezone;`)
- Application timezone: Europe/Berlin (confirmed via `Intl.DateTimeFormat().resolvedOptions().timeZone`)

Which area(s) are affected? (Select all that apply)

Backend, Package

Auth config (if applicable)

import { db } from '@/db/database';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { emailOTP, openAPI } from 'better-auth/plugins';
import * as schema from '@/db/schema';
import { emailService } from '@/services/email/email.service';

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: schema
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true
  },
  socialProviders: {},
  plugins: [
    emailOTP({
      disableSignUp: true,
      otpLength: 6,
      expiresIn: 300, // 5 minutes
      sendVerificationOnSignUp: true,
      async sendVerificationOTP({ email, otp, type }) {
        let subject = '';
        let html = '';

        switch (type) {
          case 'sign-in':
            subject = 'Sign in to ...';
            html = `
              <h1>Sign in to ...</h1>
              <p>Your one-time password is:</p>
              <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2>
              <p>This code will expire in 5 minutes.</p>
            `;
            break;
          case 'email-verification':
            subject = 'Verify your email for ...';
            html = `
              <h1>Verify your email</h1>
              <p>Your verification code is:</p>
              <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2>
              <p>This code will expire in 5 minutes.</p>
            `;
            break;
          case 'forget-password':
            subject = 'Reset your password for ...';
            html = `
              <h1>Reset your password</h1>
              <p>Your password reset code is:</p>
              <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2>
              <p>This code will expire in 5 minutes.</p>
            `;
            break;
        }

        await emailService.sendEmail({
          to: email,
          subject,
          html
        });
      }
    }),
    openAPI({
      path: '/reference'
    })
  ]
});

Additional context

No response

Originally created by @ingokpp on GitHub (Jan 26, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ## Description When using the Email OTP plugin, there appears to be a timezone mismatch between the stored expiration time and local time. The OTP expiration timestamp in the database is stored in UTC but doesn't seem to be properly converted when compared with the local time. ### Steps to Reproduce 1. Configure Email OTP plugin with `expiresIn: 300` (5 minutes) 2. Request an OTP at local time 12:35 CET 3. Check the database - expiration timestamp shows as 11:39 UTC 4. The difference between expiration and creation time ### Current vs. Expected behavior ### Current Behavior The OTP expires earlier than expected because the expiration time seems to be handled in UTC without proper timezone conversion. For example: - Local time: 12:35 CET - Expiration timestamp in DB: 11:39 UTC - Expected expiration: Local time + 5 minutes - Actual expiration: UTC time + 5 minutes ### Expected Behavior The OTP should expire exactly 5 minutes after creation, regardless of the timezone setting. The expiration check should properly account for the timezone difference between UTC (stored in DB) and the local timezone. ### Additional Context - PostgreSQL is correctly configured with timezone settings - Node.js application correctly identifies timezone as Europe/Berlin - The issue appears to be in how the expiration timestamp is handled when validating OTPs ### What version of Better Auth are you using? ^1.1.14 ### Provide environment information ```bash - Database: PostgreSQL 16 - Database timezone: Europe/Berlin (confirmed via `SHOW timezone;`) - Application timezone: Europe/Berlin (confirmed via `Intl.DateTimeFormat().resolvedOptions().timeZone`) ``` ### Which area(s) are affected? (Select all that apply) Backend, Package ### Auth config (if applicable) ```typescript import { db } from '@/db/database'; import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { emailOTP, openAPI } from 'better-auth/plugins'; import * as schema from '@/db/schema'; import { emailService } from '@/services/email/email.service'; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: schema }), emailAndPassword: { enabled: true, requireEmailVerification: true }, socialProviders: {}, plugins: [ emailOTP({ disableSignUp: true, otpLength: 6, expiresIn: 300, // 5 minutes sendVerificationOnSignUp: true, async sendVerificationOTP({ email, otp, type }) { let subject = ''; let html = ''; switch (type) { case 'sign-in': subject = 'Sign in to ...'; html = ` <h1>Sign in to ...</h1> <p>Your one-time password is:</p> <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2> <p>This code will expire in 5 minutes.</p> `; break; case 'email-verification': subject = 'Verify your email for ...'; html = ` <h1>Verify your email</h1> <p>Your verification code is:</p> <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2> <p>This code will expire in 5 minutes.</p> `; break; case 'forget-password': subject = 'Reset your password for ...'; html = ` <h1>Reset your password</h1> <p>Your password reset code is:</p> <h2 style="font-size: 24px; padding: 10px; background: #f5f5f5; border-radius: 5px;">${otp}</h2> <p>This code will expire in 5 minutes.</p> `; break; } await emailService.sendEmail({ to: email, subject, html }); } }), openAPI({ path: '/reference' }) ] }); ``` ### Additional context _No response_
GiteaMirror added the bug label 2026-03-13 07:55:30 -05:00
Author
Owner

@ingokpp commented on GitHub (Jan 26, 2025):

I think the problem could lay done in the getDate util.

Not 100% sure but i think "return new Date(Date.now() + (unit === "sec" ? span * 1000 : span));" where new Date creates a date in the local timezone (eg. GMT) and Date.now() always returns a UTC timestamp.

@ingokpp commented on GitHub (Jan 26, 2025): I think the problem could lay done in the getDate util. Not 100% sure but i think "return new Date(Date.now() + (unit === "sec" ? span * 1000 : span));" where new Date creates a date in the local timezone (eg. GMT) and Date.now() always returns a UTC timestamp.
Author
Owner

@izakfilmalter commented on GitHub (Jun 1, 2025):

I am running into this same issue. I am using SQL from Bun, so that might be why it's happening to me in this new app. My last app used postgres.js and I don't have this issue.

@izakfilmalter commented on GitHub (Jun 1, 2025): I am running into this same issue. I am using SQL from Bun, so that might be why it's happening to me in this new app. My last app used postgres.js and I don't have this issue.
Author
Owner

@emersonlaurentino commented on GitHub (Jun 22, 2025):

I have the same problem using Bun SQL

@emersonlaurentino commented on GitHub (Jun 22, 2025): I have the same problem using Bun SQL
Author
Owner

@emersonlaurentino commented on GitHub (Jun 22, 2025):

@izakfilmalter apparently it's a bug on bun, you can see more details in this issue https://github.com/drizzle-team/drizzle-orm/issues/4311

but you can do a workaround using timestampz:

  expiresAt: timestamp({ withTimezone: true }).notNull()
@emersonlaurentino commented on GitHub (Jun 22, 2025): @izakfilmalter apparently it's a bug on bun, you can see more details in this issue https://github.com/drizzle-team/drizzle-orm/issues/4311 but you can do a workaround using `timestampz`: ```ts expiresAt: timestamp({ withTimezone: true }).notNull() ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#592