Invalid Token for every reset password request #1524

Closed
opened 2026-03-13 08:45:10 -05:00 by GiteaMirror · 21 comments
Owner

Originally created by @JuanPabloGilA on GitHub (Jul 19, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

With this repository you can reproduce it.

https://github.com/LoannPowell/hono-react-boilerplate``

Current vs. Expected behavior

Requesting token:

Auth route accessed: POST /api/auth/forget-password
URL: http://localhost:3000/api/auth/reset-password/jLVNWxorayJxBDHCa2iQnKAv?callbackURL=%2Freset-password
Token: jLVNWxorayJxBDHCa2iQnKAv
user myemail@gmail.com
--> POST /api/auth/forget-password 200 779ms

Sending token with password:

<-- POST /api/auth/reset-password
Auth route accessed: POST /api/auth/reset-password
--> POST /api/auth/reset-password 400 311ms

In the database:


, {
    id: "4WSXCjrRuzU0GD2FACsnvNVorv1uh9L7",
    identifier: "reset-password:5NiJmwlsBv9stz936obLVfVg",
    value: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv",
    expiresAt: 2025-07-19T07:05:48.000Z,
    createdAt: 2025-07-19T06:05:48.000Z,
    updatedAt: 2025-07-19T06:05:48.000Z,
  }, {
    id: "85Dn9APZo8q4DhPpUHfzWzgcjb3wrV0S",
    identifier: "reset-password:jLVNWxorayJxBDHCa2iQnKAv",
    value: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv",
    expiresAt: 2025-07-19T07:05:57.000Z,
    createdAt: 2025-07-19T06:05:57.000Z,
    updatedAt: 2025-07-19T06:05:57.000Z,
  }
]
[
  {
    id: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv",
    name: "juan",
    email: "myemail@gmail.com",
    emailVerified: true,
    image: null,
    createdAt: 2025-07-17T00:51:03.000Z,
    updatedAt: 2025-07-17T00:51:03.000Z,
  }
]


What version of Better Auth are you using?

1.2.12

Provide environment information

- Os [Windows 11]
= Browser Chrome

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

Backend

Auth config (if applicable)

export const auth =  betterAuth({
    database: drizzleAdapter(db, { provider: 'pg', schema: {
        user,
        session,
        account,
        verification,
    }}),
    baseURL: env.BETTER_AUTH_URL,
    secret: env.BETTER_AUTH_SECRET,
    emailAndPassword: {  
        enabled: true,
        requireEmailVerification: true,
        sendResetPassword: async ({user, url, token}) => {
            const html = generateEmailHTML({
                link: url,
                type: 'password-reset',
                userName: user.name
            });
           
           await resend.emails.send({
                from: 'noreply@transanctional.optioo.io',
                to: user.email,
                subject: 'Reset your password',
                html: html,
                text: `Hello ${user.name}, We received a request to reset your password. Please visit this link to reset your password: ${url}`,
            });
        }
    },
    trustedOrigins: ['http://localhost:5173'],
    session: { 
        cookieCache: {
        enabled: true,
        maxAge: 5 * 60 
    }},
    emailVerification: {
        sendOnSignUp: true,
        autoSignInAfterVerification: true,
        sendVerificationEmail: async ({user, url}) => { 
            const html = generateEmailHTML({
                link: url,
                type: 'email-verification',
                userName: user.name
            });
            
            await resend.emails.send({
                from: 'noreply@transanctional.optioo.io',
                to: user.email,
                subject: 'Verify your email',
                html: html,
                text: `Hello ${user.name}, Please verify your email address by visiting this link: ${url}`,
            }); 

        }
    },
    pages: {
        resetPassword: {
            redirectTo: "http://localhost:5173/reset-password"
        }
    }
});

Additional context

Happening for the API. In every browser.

Originally created by @JuanPabloGilA on GitHub (Jul 19, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce With this repository you can reproduce it. https://github.com/LoannPowell/hono-react-boilerplate`` ### Current vs. Expected behavior Requesting token: ``` Auth route accessed: POST /api/auth/forget-password URL: http://localhost:3000/api/auth/reset-password/jLVNWxorayJxBDHCa2iQnKAv?callbackURL=%2Freset-password Token: jLVNWxorayJxBDHCa2iQnKAv user myemail@gmail.com --> POST /api/auth/forget-password 200 779ms ``` Sending token with password: ``` <-- POST /api/auth/reset-password Auth route accessed: POST /api/auth/reset-password --> POST /api/auth/reset-password 400 311ms ``` In the database: ``` , { id: "4WSXCjrRuzU0GD2FACsnvNVorv1uh9L7", identifier: "reset-password:5NiJmwlsBv9stz936obLVfVg", value: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv", expiresAt: 2025-07-19T07:05:48.000Z, createdAt: 2025-07-19T06:05:48.000Z, updatedAt: 2025-07-19T06:05:48.000Z, }, { id: "85Dn9APZo8q4DhPpUHfzWzgcjb3wrV0S", identifier: "reset-password:jLVNWxorayJxBDHCa2iQnKAv", value: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv", expiresAt: 2025-07-19T07:05:57.000Z, createdAt: 2025-07-19T06:05:57.000Z, updatedAt: 2025-07-19T06:05:57.000Z, } ] [ { id: "WsHGoub6GjEvqh0rTyGHNbP1sBvjV7Zv", name: "juan", email: "myemail@gmail.com", emailVerified: true, image: null, createdAt: 2025-07-17T00:51:03.000Z, updatedAt: 2025-07-17T00:51:03.000Z, } ] ``` ### What version of Better Auth are you using? 1.2.12 ### Provide environment information ```bash - Os [Windows 11] = Browser Chrome ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: { user, session, account, verification, }}), baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({user, url, token}) => { const html = generateEmailHTML({ link: url, type: 'password-reset', userName: user.name }); await resend.emails.send({ from: 'noreply@transanctional.optioo.io', to: user.email, subject: 'Reset your password', html: html, text: `Hello ${user.name}, We received a request to reset your password. Please visit this link to reset your password: ${url}`, }); } }, trustedOrigins: ['http://localhost:5173'], session: { cookieCache: { enabled: true, maxAge: 5 * 60 }}, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({user, url}) => { const html = generateEmailHTML({ link: url, type: 'email-verification', userName: user.name }); await resend.emails.send({ from: 'noreply@transanctional.optioo.io', to: user.email, subject: 'Verify your email', html: html, text: `Hello ${user.name}, Please verify your email address by visiting this link: ${url}`, }); } }, pages: { resetPassword: { redirectTo: "http://localhost:5173/reset-password" } } }); ``` ### Additional context Happening for the API. In every browser.
Author
Owner

@dosubot[bot] commented on GitHub (Jul 19, 2025):

Hi @LoannPowell! I'm Dosu and I’m helping the better-auth team.

This is a known issue with Better Auth: the reset password flow expects you to use the redirectTo parameter when requesting a password reset, not callbackURL. The backend then generates a reset link with callbackURL derived from redirectTo and sends it to the user. If you use callbackURL directly or it's missing/incorrect, you'll get INVALID_TOKEN errors, even if the token is present in the database and not expired [source].

To fix this, make sure your frontend requests a password reset using redirectTo (for example, redirectTo: "/reset-password"), and that your reset link in the email uses the URL provided by Better Auth (which will include callbackURL automatically). When submitting the new password, send the token from the reset link as a body field or query parameter—your current frontend code already does this part correctly.

If you update your frontend to use redirectTo as described, the reset password flow should work as expected.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

@dosubot[bot] commented on GitHub (Jul 19, 2025): <!-- Greeting --> Hi @LoannPowell! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known issue with Better Auth: the reset password flow expects you to use the redirectTo parameter when requesting a password reset, not callbackURL. The backend then generates a reset link with callbackURL derived from redirectTo and sends it to the user. If you use callbackURL directly or it's missing/incorrect, you'll get INVALID_TOKEN errors, even if the token is present in the database and not expired [[source]](https://github.com/better-auth/better-auth/issues/3060). To fix this, make sure your frontend requests a password reset using redirectTo (for example, redirectTo: "/reset-password"), and that your reset link in the email uses the URL provided by Better Auth (which will include callbackURL automatically). When submitting the new password, send the token from the reset link as a body field or query parameter—your current frontend code already does this part correctly. If you update your frontend to use redirectTo as described, the reset password flow should work as expected. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/508f6955-9642-46d6-9443-04a339ef54bf?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3461)
Author
Owner

@JuanPabloGilA commented on GitHub (Jul 19, 2025):

@dosu. The repository is using redirectTo instead of CallbackURL

@JuanPabloGilA commented on GitHub (Jul 19, 2025): [@dosu](https://go.dosu.dev/dosubot). The repository is using redirectTo instead of CallbackURL
Author
Owner

@dosubot[bot] commented on GitHub (Jul 19, 2025):

You're using redirectTo correctly, which is the recommended approach for the reset password flow. The most common causes of persistent "Invalid Token" errors at this stage are: the frontend sending the wrong value as the token (for example, sending the full identifier like reset-password:token instead of just the token part), or a mismatch in how the token is extracted from the reset link and submitted to the backend. The backend expects only the token segment, not the full identifier, and it must match the identifier format in the database (reset-password:{token}) for validation to succeed. Double-check that your frontend is extracting just the token from the URL and passing it as a query parameter or in the request body as expected by Better Auth. This pattern is confirmed in the codebase and tests, and is the most likely source of this error if all config is correct reference.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

@dosubot[bot] commented on GitHub (Jul 19, 2025): <!-- Answer --> You're using `redirectTo` correctly, which is the recommended approach for the reset password flow. The most common causes of persistent "Invalid Token" errors at this stage are: the frontend sending the wrong value as the token (for example, sending the full identifier like `reset-password:token` instead of just the token part), or a mismatch in how the token is extracted from the reset link and submitted to the backend. The backend expects only the token segment, not the full identifier, and it must match the identifier format in the database (`reset-password:{token}`) for validation to succeed. Double-check that your frontend is extracting just the token from the URL and passing it as a query parameter or in the request body as expected by Better Auth. This pattern is confirmed in the codebase and tests, and is the most likely source of this error if all config is correct [reference](https://github.com/better-auth/better-auth/blob/8e4f9b6c6fe9b1d97b7fd6f615e0b120b47e87b0/packages/better-auth/src/api/routes/reset-password.ts). <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3461)
Author
Owner

@JuanPabloGilA commented on GitHub (Jul 19, 2025):

You're using redirectTo correctly, which is the recommended approach for the reset password flow. The most common causes of persistent "Invalid Token" errors at this stage are: the frontend sending the wrong value as the token (for example, sending the full identifier like reset-password:token instead of just the token part), or a mismatch in how the token is extracted from the reset link and submitted to the backend. The backend expects only the token segment, not the full identifier, and it must match the identifier format in the database (reset-password:{token}) for validation to succeed. Double-check that your frontend is extracting just the token from the URL and passing it as a query parameter or in the request body as expected by Better Auth. This pattern is confirmed in the codebase and tests, and is the most likely source of this error if all config is correct reference.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

The token is getting send correctly and the password too.

@JuanPabloGilA commented on GitHub (Jul 19, 2025): > <!-- Answer --> > You're using `redirectTo` correctly, which is the recommended approach for the reset password flow. The most common causes of persistent "Invalid Token" errors at this stage are: the frontend sending the wrong value as the token (for example, sending the full identifier like `reset-password:token` instead of just the token part), or a mismatch in how the token is extracted from the reset link and submitted to the backend. The backend expects only the token segment, not the full identifier, and it must match the identifier format in the database (`reset-password:{token}`) for validation to succeed. Double-check that your frontend is extracting just the token from the URL and passing it as a query parameter or in the request body as expected by Better Auth. This pattern is confirmed in the codebase and tests, and is the most likely source of this error if all config is correct [reference](https://github.com/better-auth/better-auth/blob/8e4f9b6c6fe9b1d97b7fd6f615e0b120b47e87b0/packages/better-auth/src/api/routes/reset-password.ts). > > > <!-- Dosu Comment Footer --> > *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* > > --- > <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/4345a390-4131-4c2b-87f8-0eafc1fa8ea5?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3461) The token is getting send correctly and the password too.
Author
Owner

@JuanPabloGilA commented on GitHub (Jul 19, 2025):

What does this PR do?

Fixes a bug where PostgreSQL schemas were being generated with timestamp (without timezone), causing expiration checks to break due to UTC mismatches.

Why is it needed?

BetterAuth tokens such as reset-password or verification-token expire immediately if the server and DB timezones differ. This aligns the schema to generate timestamp with time zone.

Changes

  • Updated PostgreSQL schema generation to use timestamp(..., { withTimezone: true })

PR incoming 🚀

@JuanPabloGilA commented on GitHub (Jul 19, 2025): ### What does this PR do? Fixes a bug where PostgreSQL schemas were being generated with `timestamp` (without timezone), causing expiration checks to break due to UTC mismatches. ### Why is it needed? BetterAuth tokens such as `reset-password` or `verification-token` expire immediately if the server and DB timezones differ. This aligns the schema to generate `timestamp with time zone`. ### Changes - Updated PostgreSQL schema generation to use `timestamp(..., { withTimezone: true })` PR incoming 🚀
Author
Owner

@frectonz commented on GitHub (Sep 4, 2025):

@LoannPowell Are you still facing this problem in the latest version of better auth.

@frectonz commented on GitHub (Sep 4, 2025): @LoannPowell Are you still facing this problem in the latest version of better auth.
Author
Owner

@JuanPabloGilA commented on GitHub (Sep 4, 2025):

@LoannPowell Are you still facing this problem in the latest version of better auth.

https://github.com/better-auth/better-auth/pull/3477

This PR fix it. The issue is with the generators, if the developer and the database have a different time zone it fails.

It was failing for me because i was in Colombia and pointing to a dev database in the US.

@JuanPabloGilA commented on GitHub (Sep 4, 2025): > [@LoannPowell](https://github.com/LoannPowell) Are you still facing this problem in the latest version of better auth. https://github.com/better-auth/better-auth/pull/3477 This PR fix it. The issue is with the generators, if the developer and the database have a different time zone it fails. It was failing for me because i was in Colombia and pointing to a dev database in the US.
Author
Owner

@frectonz commented on GitHub (Sep 5, 2025):

@JuanPabloGilA From my understanding what this issue is requesting and what the PR are trying to add support is for a situation in which the database is using a different timezone and the server is using a different timezone.

This can be solved by setting the timezone in your database to UTC.

ALTER DATABASE your_db SET timezone TO 'UTC';

At this point in time we don't want to switch to a timestamp with timezone, we want to continue using a regular timestamp with the assumption that the timezone set to UTC.

@frectonz commented on GitHub (Sep 5, 2025): @JuanPabloGilA From my understanding what this issue is requesting and what the PR are trying to add support is for a situation in which the database is using a different timezone and the server is using a different timezone. This can be solved by setting the timezone in your database to `UTC`. ```sql ALTER DATABASE your_db SET timezone TO 'UTC'; ``` At this point in time we don't want to switch to a `timestamp with timezone`, we want to continue using a regular `timestamp` with the assumption that the timezone set to UTC.
Author
Owner

@JuanPabloGilA commented on GitHub (Sep 5, 2025):

@JuanPabloGilA From my understanding what this issue is requesting and what the PR are trying to add support for a situation in which the database is using a different timezone and the server is using a different timezone.

This can be solved by setting the timezone in your database to UTC.

ALTER DATABASE your_db SET timezone TO 'UTC';
At this point in time we don't want to switch to a timestamp with timezone, we want to continue using a regular timestamp with the assumption that the timezone set to UTC.

My database is already in UTC.

In this function:

export const getDate = (span: number, unit: "sec" | "ms" = "ms") => {
	return new Date(Date.now() + (unit === "sec" ? span * 1000 : span));
};

JavaScript creates the Date using the server timezone, and when sending that to Postgres, Postgres will transform it according to the database/session timezone. That’s why, if the API and the database are in different timezones, there will always be inconsistencies. Using timestamptz is the only way to avoid that and it matches the behavior MySQL developers usually expect with TIMESTAMP.

You can test this easily: create a database in a different timezone (e.g. Europe) and compare with your server timezone. With timestamp you’ll see big differences between the stored values, but with timestamptz both will point to the same absolute instant.

This fix is mostly about developer experience in distributed teams working with shared online databases. Otherwise, every developer would need to ensure that both their local database and their server always run in the same timezone, and any mismatch silently causes incorrect data.

@JuanPabloGilA commented on GitHub (Sep 5, 2025): > [@JuanPabloGilA](https://github.com/JuanPabloGilA) From my understanding what this issue is requesting and what the PR are trying to add support for a situation in which the database is using a different timezone and the server is using a different timezone. > > This can be solved by setting the timezone in your database to `UTC`. > > ALTER DATABASE your_db SET timezone TO 'UTC'; > At this point in time we don't want to switch to a `timestamp with timezone`, we want to continue using a regular `timestamp` with the assumption that the timezone set to UTC. My database is already in UTC. In this function: ``` export const getDate = (span: number, unit: "sec" | "ms" = "ms") => { return new Date(Date.now() + (unit === "sec" ? span * 1000 : span)); }; ``` JavaScript creates the Date using the server timezone, and when sending that to Postgres, Postgres will transform it according to the database/session timezone. That’s why, if the API and the database are in different timezones, there will always be inconsistencies. Using timestamptz is the only way to avoid that and it matches the behavior MySQL developers usually expect with TIMESTAMP. You can test this easily: create a database in a different timezone (e.g. Europe) and compare with your server timezone. With timestamp you’ll see big differences between the stored values, but with timestamptz both will point to the same absolute instant. This fix is mostly about developer experience in distributed teams working with shared online databases. Otherwise, every developer would need to ensure that both their local database and their server always run in the same timezone, and any mismatch silently causes incorrect data.
Author
Owner

@frectonz commented on GitHub (Sep 5, 2025):

An easy fix for this for now is to make sure both your server and your database are using the same timezone. You can use the TZ env var to the set the timezone for your node server. Like this TZ=UTC.

@frectonz commented on GitHub (Sep 5, 2025): An easy fix for this for now is to make sure both your server and your database are using the same timezone. You can use the `TZ` env var to the set the timezone for your node server. Like this `TZ=UTC`.
Author
Owner

@himself65 commented on GitHub (Sep 5, 2025):

Yeah, that will be a lot easier. But I do think we should add an option to enable timezonez

@himself65 commented on GitHub (Sep 5, 2025): Yeah, that will be a lot easier. But I do think we should add an option to enable timezonez
Author
Owner

@frectonz commented on GitHub (Sep 7, 2025):

@JuanPabloGilA I have looked into this issue more from looking at the drizzle codebase and the better-auth codebase. I have confirmed that we always use UTC via toISOString() when writing dates to the database and we also make sure to use UTC when we are reading dates from the database.

I was not able to reproduce this bug.

Did you make any modifications to the schema or introduced some code to change this behaviour. It will also be good if you can share a project that reproduces this problem.

@frectonz commented on GitHub (Sep 7, 2025): @JuanPabloGilA I have looked into this issue more from looking at the `drizzle` codebase and the `better-auth` codebase. I have confirmed that we always use `UTC` via `toISOString()` when writing dates to the database and we also make sure to use `UTC` when we are reading dates from the database. I was not able to reproduce this bug. Did you make any modifications to the schema or introduced some code to change this behaviour. It will also be good if you can share a project that reproduces this problem.
Author
Owner

@Shuumatsu commented on GitHub (Sep 9, 2025):

I also encountered time zone issues. When I used pg, magic link token validation worked fine.

return betterAuth({
    database: new Pool({
        connectionString: env.DATABASE_URL,
    }),
    ...
}

But when I used Kysely PostgresJS or Neon, any token was considered expired.

export const create = (env: Env) => {
    return new PostgresJSDialect({
        postgres: postgres(env.DATABASE_URL),
    })
}

export type Dependencies = {
    database: PostgresJSDialect
    emailService: EmailService
}

const { database, emailService } = dependencies
return betterAuth({
    database,
    ...
}

I think no matter how you implement the internal logic, you should at least ensure consistent behavior. This is currently a bug.

@Shuumatsu commented on GitHub (Sep 9, 2025): I also encountered time zone issues. When I used pg, magic link token validation worked fine. ``` return betterAuth({ database: new Pool({ connectionString: env.DATABASE_URL, }), ... } ``` But when I used Kysely PostgresJS or Neon, any token was considered expired. ``` export const create = (env: Env) => { return new PostgresJSDialect({ postgres: postgres(env.DATABASE_URL), }) } export type Dependencies = { database: PostgresJSDialect emailService: EmailService } const { database, emailService } = dependencies return betterAuth({ database, ... } ``` I think no matter how you implement the internal logic, you should at least ensure consistent behavior. This is currently a bug.
Author
Owner

@frectonz commented on GitHub (Sep 9, 2025):

Oh we mostly looked at the drizzle adapter since that was what the person who created this issue was using. @Shuumatsu Do you have a repo that reproduces the problem with kysely?

@frectonz commented on GitHub (Sep 9, 2025): Oh we mostly looked at the `drizzle` adapter since that was what the person who created this issue was using. @Shuumatsu Do you have a repo that reproduces the problem with `kysely`?
Author
Owner

@Shuumatsu commented on GitHub (Sep 9, 2025):

Oh we mostly looked at the drizzle adapter since that was what the person who created this issue was using. @Shuumatsu Do you have a repo that reproduces the problem with kysely?

I just created a new repo that eliminates all other parts of my project, it's minimal and you can completely start over to reproduce this error
https://github.com/Shuumatsu/better-auth-timezome-issue
I included descriptions in the readme file

@Shuumatsu commented on GitHub (Sep 9, 2025): > Oh we mostly looked at the `drizzle` adapter since that was what the person who created this issue was using. [@Shuumatsu](https://github.com/Shuumatsu) Do you have a repo that reproduces the problem with `kysely`? I just created a new repo that eliminates all other parts of my project, it's minimal and you can completely start over to reproduce this error https://github.com/Shuumatsu/better-auth-timezome-issue I included descriptions in the readme file
Author
Owner

@frectonz commented on GitHub (Sep 9, 2025):

Yeah thanks I have narrowed down the issue even further, the source of the problem is kysely using the server's local time to create the Date object instead of defaulting to UTC for timestamp column.

https://github.com/frectonz/timestamp-investigations/blob/main/src/kysely.ts

@frectonz commented on GitHub (Sep 9, 2025): Yeah thanks I have narrowed down the issue even further, the source of the problem is `kysely` using the server's local time to create the `Date` object instead of defaulting to `UTC` for `timestamp` column. https://github.com/frectonz/timestamp-investigations/blob/main/src/kysely.ts
Author
Owner

@Shuumatsu commented on GitHub (Sep 27, 2025):

@frectonz I just updated the package deps and now using 1.3.18 but still faced the same issue.
https://github.com/Shuumatsu/better-auth-timezome-issue/tree/1.3.18

@Shuumatsu commented on GitHub (Sep 27, 2025): @frectonz I just updated the package deps and now using 1.3.18 but still faced the same issue. https://github.com/Shuumatsu/better-auth-timezome-issue/tree/1.3.18
Author
Owner

@Shuumatsu commented on GitHub (Sep 30, 2025):

Hey @frectonz @himself65 do you have any updates on this?

@Shuumatsu commented on GitHub (Sep 30, 2025): Hey @frectonz @himself65 do you have any updates on this?
Author
Owner

@frectonz commented on GitHub (Sep 30, 2025):

The problem exists in the postgres package you are using it seems like it is just passing the string it gets from the database into new Date irregardless of the column being a timestamp or a timestamptz. 32feb259a3/cjs/src/types.js (L32)

The tests that were added in #4542 are run using the pg library, which properly treats timestampz columns as UTC. So i recommend using pg. That's what's also recommended on the getting started page.

Image

This is the test we added to catch this issue, if you want to take a look at it. 981458338d/packages/better-auth/src/adapters/kysely-adapter/test/normal/adapter.kysely.test.ts (L392)

Since we have updated the timestamp column to be a timestampz be sure to run the migrations, that could be the other cause of this problem.

@frectonz commented on GitHub (Sep 30, 2025): The problem exists in the `postgres` package you are using it seems like it is just passing the string it gets from the database into `new Date` irregardless of the column being a `timestamp` or a `timestamptz`. https://github.com/porsager/postgres/blob/32feb259a3c9abffab761bd1758b3168d9e0cebc/cjs/src/types.js#L32 The tests that were added in #4542 are run using the `pg` library, which properly treats `timestampz` columns as `UTC`. So i recommend using `pg`. That's what's also recommended on the getting started page. <img width="853" height="404" alt="Image" src="https://github.com/user-attachments/assets/1a45269e-ca09-4b3d-8358-1277069b6d16" /> This is the test we added to catch this issue, if you want to take a look at it. https://github.com/better-auth/better-auth/blob/981458338d3638a6f45347559d6f086d9298489f/packages/better-auth/src/adapters/kysely-adapter/test/normal/adapter.kysely.test.ts#L392 **Since we have updated the `timestamp` column to be a `timestampz` be sure to run the migrations, that could be the other cause of this problem.**
Author
Owner

@ehudsn commented on GitHub (Oct 17, 2025):

An easy fix for this for now is to make sure both your server and your database are using the same timezone. You can use the TZ env var to the set the timezone for your node server. Like this TZ=UTC.

This was the answer for me, even on the latest version. Running a dev db on the East Coast, but in Central timezone.

@ehudsn commented on GitHub (Oct 17, 2025): > An easy fix for this for now is to make sure both your server and your database are using the same timezone. You can use the `TZ` env var to the set the timezone for your node server. Like this `TZ=UTC`. This was the answer for me, even on the latest version. Running a dev db on the East Coast, but in Central timezone.
Author
Owner

@danirisdiandita commented on GitHub (Feb 28, 2026):

I fix this by adding token to the resetPassword function in the set password page, probably the reset password link token cannot be catched automatically by the reset password function from your url

"use client";

import { Suspense, useState, useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useRouter, useSearchParams } from "next/navigation";

function ResetPasswordForm() {
    const [password, setPassword] = useState("");
    const [confirmPassword, setConfirmPassword] = useState("");
    const [loading, setLoading] = useState(false);
    const [token, setToken] = useState<string | null>(null);
    const router = useRouter();
    const searchParams = useSearchParams();

    useEffect(() => {
        const t = searchParams.get("token");
        if (t) {
            setToken(t);
        }
    }, [searchParams]);

    const handleResetPassword = async (e: React.FormEvent) => {
        e.preventDefault();

        if (!token) {
            alert("Reset token is missing. Please use the link from your email.");
            return;
        }

        if (password !== confirmPassword) {
            alert("Passwords do not match");
            return;
        }

        setLoading(true);
        const { error } = await authClient.resetPassword({
            newPassword: password,
            token: token,
        });
        setLoading(false);

        if (!error) {
            alert("Password reset successfully!");
            router.push("/login");
        } else {
            alert(error.message || "Something went wrong. The token may be invalid or expired.");
        }
    };

    return (
        <Card className="border-zinc-800 bg-zinc-900 text-white shadow-2xl">
            <CardHeader className="space-y-1">
                <CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
                <CardDescription className="text-zinc-400">
                    Enter your new password below.
                </CardDescription>
            </CardHeader>
            <form onSubmit={handleResetPassword}>
                <CardContent className="space-y-4">
                    <div className="space-y-2">
                        <Label htmlFor="password">New Password</Label>
                        <Input
                            id="password"
                            type="password"
                            placeholder="••••••••"
                            className="border-zinc-800 bg-zinc-950 focus-visible:ring-indigo-500"
                            value={password}
                            onChange={(e) => setPassword(e.target.value)}
                            required
                        />
                    </div>
                    <div className="space-y-2">
                        <Label htmlFor="confirmPassword">Confirm New Password</Label>
                        <Input
                            id="confirmPassword"
                            type="password"
                            placeholder="••••••••"
                            className="border-zinc-800 bg-zinc-950 focus-visible:ring-indigo-500"
                            value={confirmPassword}
                            onChange={(e) => setConfirmPassword(e.target.value)}
                            required
                        />
                    </div>
                </CardContent>
                <CardFooter>
                    <Button type="submit" className="w-full bg-indigo-600 hover:bg-indigo-700 text-white transition-all" disabled={loading || !token}>
                        {loading ? "Resetting..." : "Reset Password"}
                    </Button>
                </CardFooter>
            </form>
        </Card>
    );
}

export default function ResetPasswordPage() {
    return (
        <div className="flex min-h-screen items-center justify-center bg-zinc-950 px-4 py-12">
            <div className="mx-auto w-full max-w-md space-y-8">
                <div className="text-center">
                    <h1 className="text-3xl font-bold tracking-tight text-white">Widya Video Platform</h1>
                </div>

                <Suspense fallback={
                    <Card className="border-zinc-800 bg-zinc-900 text-white shadow-2xl">
                        <CardHeader className="space-y-1">
                            <CardTitle className="text-2xl font-bold">Loading...</CardTitle>
                        </CardHeader>
                    </Card>
                }>
                    <ResetPasswordForm />
                </Suspense>
            </div>
        </div>
    );
}

see this part

   const { error } = await authClient.resetPassword({
            newPassword: password,
            token: token,
        });

in which authClient is from

import { createAuthClient } from "better-auth/react";
import { CLIENT_CONFIG } from "@/constants/config";

export const authClient = createAuthClient({
    baseURL: CLIENT_CONFIG.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});

@danirisdiandita commented on GitHub (Feb 28, 2026): I fix this by adding token to the resetPassword function in the set password page, probably the reset password link token cannot be catched automatically by the reset password function from your url ``` "use client"; import { Suspense, useState, useEffect } from "react"; import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useRouter, useSearchParams } from "next/navigation"; function ResetPasswordForm() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const [token, setToken] = useState<string | null>(null); const router = useRouter(); const searchParams = useSearchParams(); useEffect(() => { const t = searchParams.get("token"); if (t) { setToken(t); } }, [searchParams]); const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); if (!token) { alert("Reset token is missing. Please use the link from your email."); return; } if (password !== confirmPassword) { alert("Passwords do not match"); return; } setLoading(true); const { error } = await authClient.resetPassword({ newPassword: password, token: token, }); setLoading(false); if (!error) { alert("Password reset successfully!"); router.push("/login"); } else { alert(error.message || "Something went wrong. The token may be invalid or expired."); } }; return ( <Card className="border-zinc-800 bg-zinc-900 text-white shadow-2xl"> <CardHeader className="space-y-1"> <CardTitle className="text-2xl font-bold">Reset Password</CardTitle> <CardDescription className="text-zinc-400"> Enter your new password below. </CardDescription> </CardHeader> <form onSubmit={handleResetPassword}> <CardContent className="space-y-4"> <div className="space-y-2"> <Label htmlFor="password">New Password</Label> <Input id="password" type="password" placeholder="••••••••" className="border-zinc-800 bg-zinc-950 focus-visible:ring-indigo-500" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> <div className="space-y-2"> <Label htmlFor="confirmPassword">Confirm New Password</Label> <Input id="confirmPassword" type="password" placeholder="••••••••" className="border-zinc-800 bg-zinc-950 focus-visible:ring-indigo-500" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required /> </div> </CardContent> <CardFooter> <Button type="submit" className="w-full bg-indigo-600 hover:bg-indigo-700 text-white transition-all" disabled={loading || !token}> {loading ? "Resetting..." : "Reset Password"} </Button> </CardFooter> </form> </Card> ); } export default function ResetPasswordPage() { return ( <div className="flex min-h-screen items-center justify-center bg-zinc-950 px-4 py-12"> <div className="mx-auto w-full max-w-md space-y-8"> <div className="text-center"> <h1 className="text-3xl font-bold tracking-tight text-white">Widya Video Platform</h1> </div> <Suspense fallback={ <Card className="border-zinc-800 bg-zinc-900 text-white shadow-2xl"> <CardHeader className="space-y-1"> <CardTitle className="text-2xl font-bold">Loading...</CardTitle> </CardHeader> </Card> }> <ResetPasswordForm /> </Suspense> </div> </div> ); } ``` see this part ``` const { error } = await authClient.resetPassword({ newPassword: password, token: token, }); ``` in which `authClient` is from ``` import { createAuthClient } from "better-auth/react"; import { CLIENT_CONFIG } from "@/constants/config"; export const authClient = createAuthClient({ baseURL: CLIENT_CONFIG.NEXT_PUBLIC_APP_URL || "http://localhost:3000", }); ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1524