[GH-ISSUE #2324] Support for Forcing Password Change on next login #9149

Open
opened 2026-04-13 04:30:38 -05:00 by GiteaMirror · 12 comments
Owner

Originally created by @moreorover on GitHub (Apr 16, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/2324

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

Currently, there is no built-in way to enforce a password change for a user upon their next login. This limits the ability to enforce security policies, especially in scenarios where an admin creates a user or a password reset is necessary.

Describe the solution you'd like

It would be great to include this functionality within the admin plugin. For example, when creating a user via authClient.admin.createUser, an optional flag could be provided:

{ requestPasswordChange: boolean } // default: true

If requestPasswordChange is set to true, then on the user's next login, they would receive a better-auth.passwordChange cookie and be redirected to a /passwordChange route.

Additionally, an admin method like authClient.admin.requestPasswordChange({ userId: string }) would be helpful to trigger this behavior after account creation or at any point when an admin needs to enforce a password update.

Describe alternatives you've considered

Considered implementing this logic outside of the Better Auth system, but it feels like a natural fit for the core feature set or the admin plugin. Implementing it within Better Auth would ensure consistency and reduce duplicated effort across applications.

Additional context

No response

Originally created by @moreorover on GitHub (Apr 16, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/2324 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. Currently, there is no built-in way to enforce a password change for a user upon their next login. This limits the ability to enforce security policies, especially in scenarios where an admin creates a user or a password reset is necessary. ### Describe the solution you'd like It would be great to include this functionality within the admin plugin. For example, when creating a user via `authClient.admin.createUser`, an optional flag could be provided: ```ts { requestPasswordChange: boolean } // default: true ``` If `requestPasswordChange` is set to true, then on the user's next login, they would receive a `better-auth.passwordChange` cookie and be redirected to a `/passwordChange` route. Additionally, an admin method like `authClient.admin.requestPasswordChange({ userId: string })` would be helpful to trigger this behavior after account creation or at any point when an admin needs to enforce a password update. ### Describe alternatives you've considered Considered implementing this logic outside of the Better Auth system, but it feels like a natural fit for the core feature set or the admin plugin. Implementing it within Better Auth would ensure consistency and reduce duplicated effort across applications. ### Additional context _No response_
GiteaMirror added the credentialsorganization labels 2026-04-13 04:30:39 -05:00
Author
Owner

@ping-maxwell commented on GitHub (Apr 20, 2025):

We'd need to save data per user about whether they've updated their password since the admin created the account.

One option is to add a new field to the user table, but I don't like this idea as it would be a boolean flag which would stay as a fixed value after user sets their password, and would be a huge waste of storage.

Alternatively, we'd create a table all just for this: This table will track if the user has updated their password, if so, delete that row in said table.
The only issue I have against this is... well it feels too much.
It's another table for the admin plugin for the soul purpose of "requestPasswordChange".
Then again, we can make it so that this table will only exists if a plugin config has enabled this feature. 🤔

<!-- gh-comment-id:2817146311 --> @ping-maxwell commented on GitHub (Apr 20, 2025): We'd need to save data per user about whether they've updated their password since the admin created the account. One option is to add a new field to the user table, but I don't like this idea as it would be a boolean flag which would stay as a fixed value after user sets their password, and would be a huge waste of storage. Alternatively, we'd create a table all just for this: This table will track if the user has updated their password, if so, delete that row in said table. The only issue I have against this is... well it feels too much. It's __another__ table for the admin plugin for the soul purpose of "requestPasswordChange". Then again, we can make it so that this table will only exists if a plugin config has enabled this feature. 🤔
Author
Owner

@moreorover commented on GitHub (Apr 20, 2025):

I'm thinking of a password change flow that works similarly to how the Two-Factor plugin works:

When 2FA is not enabled:

  1. User logs in with their credentials.
  2. If a password change is required, they get a password-change cookie.
  3. They’re redirected to /password-change to update their password.

When 2FA is enabled:

  1. User logs in with their credentials.
  2. They complete the 2FA verification.
  3. If a password change is required, they get the same password-change cookie.
  4. They’re redirected to /password-change.

This could be implemented by adding a new column to the users table to track whether a password change is required.

After thinking more about it, I believe a time-based approach would make this even better. Instead of a simple flag, we could add a column like password_updated_at. If it's null, it means the user needs to set a new password. Otherwise, if the last update was more than 90 days ago (or a configurable number), the user would be asked to change it. And I believe this approach would justify adding an extra column to the users table.

All things considered, this feature might be best as a new plugin, separate from the admin plugin, since not every project will want to enforce password updates automatically.

This would be a great addition to the plugin catalog and would help teams meet security policy requirements more easily.

<!-- gh-comment-id:2817283455 --> @moreorover commented on GitHub (Apr 20, 2025): I'm thinking of a password change flow that works similarly to how the Two-Factor plugin works: **When 2FA is not enabled:** 1. User logs in with their credentials. 2. If a password change is required, they get a password-change cookie. 3. They’re redirected to /password-change to update their password. **When 2FA is enabled:** 1. User logs in with their credentials. 2. They complete the 2FA verification. 3. If a password change is required, they get the same password-change cookie. 4. They’re redirected to /password-change. This could be implemented by adding a new column to the users table to track whether a password change is required. After thinking more about it, I believe a time-based approach would make this even better. Instead of a simple flag, we could add a column like `password_updated_at`. If it's `null`, it means the user needs to set a new password. Otherwise, if the last update was more than 90 days ago (or a configurable number), the user would be asked to change it. And I believe this approach would justify adding an extra column to the users table. All things considered, this feature might be best as a new plugin, separate from the admin plugin, since not every project will want to enforce password updates automatically. This would be a great addition to the plugin catalog and would help teams meet security policy requirements more easily.
Author
Owner

@axelrtgs commented on GitHub (May 28, 2025):

I am currently implementing this in my app using some additional fields. this would allow for some better password policies such as being able to set a password to expire every X days, tracking when passwords were last changed and being able to see the last time a user logged in.

This is to help improve security in the password usages. you can force password change on login by force invoking the change password flow if the password is expired and/or the last password change is older than X days. You are also able to check for stale accounts so you can see if any inactive accounts need to be disabled for security.

Our use case is to support forced password rotation and when inviting a user into an org we automatically create a user account for the org member with a temporary password, set the expiry date in the past so on login they are forced to change it before continuing.

account: {
	fields: {
		passwordExpiresAt: {
			type: "date",
			required: false,
		},
		passwordChangedAt: {
			type: "date",
			required: false,
		},
		lastLoginAt: {
			type: "date",
			required: false,
		},
	},
},

It would be great to see this implemented natively in better-auth

<!-- gh-comment-id:2914768791 --> @axelrtgs commented on GitHub (May 28, 2025): I am currently implementing this in my app using some additional fields. this would allow for some better password policies such as being able to set a password to expire every X days, tracking when passwords were last changed and being able to see the last time a user logged in. This is to help improve security in the password usages. you can force password change on login by force invoking the change password flow if the password is expired and/or the last password change is older than X days. You are also able to check for stale accounts so you can see if any inactive accounts need to be disabled for security. Our use case is to support forced password rotation and when inviting a user into an org we automatically create a user account for the org member with a temporary password, set the expiry date in the past so on login they are forced to change it before continuing. ``` account: { fields: { passwordExpiresAt: { type: "date", required: false, }, passwordChangedAt: { type: "date", required: false, }, lastLoginAt: { type: "date", required: false, }, }, }, ``` It would be great to see this implemented natively in better-auth
Author
Owner

@dosubot[bot] commented on GitHub (Aug 27, 2025):

Hi, @moreorover. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You requested a feature to enforce password changes on next login, allowing admins to set a flag or trigger it later.
  • The goal is to improve security by redirecting users to a password change page, similar to the Two-Factor plugin flow.
  • Suggestions include adding a time-based column like password_updated_at to track password age, possibly as a separate plugin.
  • Discussion has included options for implementation, such as adding a boolean flag to the user table versus creating a separate table, with concerns about storage and complexity.
  • A contributor shared their own implementation supporting forced password rotation and suggested native support in better-auth.

Next Steps:

  • Please let me know if this feature is still relevant to the latest version of better-auth by commenting on this issue to keep the discussion open.
  • If I don’t hear back within 7 days, I will automatically close this issue to help keep the backlog manageable.

Thanks for your understanding and contribution!

<!-- gh-comment-id:3228812977 --> @dosubot[bot] commented on GitHub (Aug 27, 2025): Hi, @moreorover. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You requested a feature to enforce password changes on next login, allowing admins to set a flag or trigger it later. - The goal is to improve security by redirecting users to a password change page, similar to the Two-Factor plugin flow. - Suggestions include adding a time-based column like `password_updated_at` to track password age, possibly as a separate plugin. - Discussion has included options for implementation, such as adding a boolean flag to the user table versus creating a separate table, with concerns about storage and complexity. - A contributor shared their own implementation supporting forced password rotation and suggested native support in better-auth. **Next Steps:** - Please let me know if this feature is still relevant to the latest version of better-auth by commenting on this issue to keep the discussion open. - If I don’t hear back within 7 days, I will automatically close this issue to help keep the backlog manageable. Thanks for your understanding and contribution!
Author
Owner

@moreorover commented on GitHub (Aug 27, 2025):

I can confirm this feature is still relevant and would be great to have it out of the box.

<!-- gh-comment-id:3228897639 --> @moreorover commented on GitHub (Aug 27, 2025): I can confirm this feature is still relevant and would be great to have it out of the box.
Author
Owner

@jonas-rude commented on GitHub (Sep 23, 2025):

Hi, also of interest for our team

<!-- gh-comment-id:3322619526 --> @jonas-rude commented on GitHub (Sep 23, 2025): Hi, also of interest for our team
Author
Owner

@depbruno commented on GitHub (Oct 16, 2025):

Bump

<!-- gh-comment-id:3410379902 --> @depbruno commented on GitHub (Oct 16, 2025): Bump
Author
Owner

@paulschuetz commented on GitHub (Nov 12, 2025):

we are also very interested 👍

<!-- gh-comment-id:3521416037 --> @paulschuetz commented on GitHub (Nov 12, 2025): we are also very interested 👍
Author
Owner

@willianrsouza commented on GitHub (Jan 2, 2026):

Já deveria* existir, recurso meio básico...

<!-- gh-comment-id:3705784205 --> @willianrsouza commented on GitHub (Jan 2, 2026): Já deveria* existir, recurso meio básico...
Author
Owner

@z-jxy commented on GitHub (Feb 16, 2026):

This is blocking migration from auth.js to better-auth for me.

For apps where users don't create their own accounts, this is a pretty hard requirement

<!-- gh-comment-id:3910552866 --> @z-jxy commented on GitHub (Feb 16, 2026): This is blocking migration from auth.js to better-auth for me. For apps where users don't create their own accounts, this is a pretty hard requirement
Author
Owner

@z-jxy commented on GitHub (Feb 16, 2026):

We'd need to save data per user about whether they've updated their password since the admin created the account.

One option is to add a new field to the user table, but I don't like this idea as it would be a boolean flag which would stay as a fixed value after user sets their password, and would be a huge waste of storage.

Alternatively, we'd create a table all just for this: This table will track if the user has updated their password, if so, delete that row in said table. The only issue I have against this is... well it feels too much. It's another table for the admin plugin for the soul purpose of "requestPasswordChange". Then again, we can make it so that this table will only exists if a plugin config has enabled this feature. 🤔

As mentioned by @moreorover and @axelrtgs, a date field like expires_at would be a lot more flexible here

<!-- gh-comment-id:3910574512 --> @z-jxy commented on GitHub (Feb 16, 2026): > We'd need to save data per user about whether they've updated their password since the admin created the account. > > One option is to add a new field to the user table, but I don't like this idea as it would be a boolean flag which would stay as a fixed value after user sets their password, and would be a huge waste of storage. > > Alternatively, we'd create a table all just for this: This table will track if the user has updated their password, if so, delete that row in said table. The only issue I have against this is... well it feels too much. It's **another** table for the admin plugin for the soul purpose of "requestPasswordChange". Then again, we can make it so that this table will only exists if a plugin config has enabled this feature. 🤔 As mentioned by @moreorover and @axelrtgs, a date field like `expires_at` would be a lot more flexible here
Author
Owner

@DibyodyutiMondal commented on GitHub (Feb 18, 2026):

I wrote a plugin that could address this issue:

import { APIError } from 'better-auth';
import { isAPIError } from 'better-auth/api';
import { createAuthMiddleware } from 'better-auth/plugins';
import { defineRequestState } from '@better-auth/core/context';
import type { Account, BetterAuthPlugin, HookEndpointContext } from 'better-auth';
import { z } from 'zod';

type CredentialAccount = Account & {
  resetRequired?: boolean | null;
  expiresAt?: Date | null;
};

const ERROR_CODE = {
  code: 'PASSWORD_RESET_REQUIRED',
  message: 'A password reset is required before you can sign in.',
} as const;

const resetPasswordAccount = defineRequestState<CredentialAccount | null>(() => null);

const withToken = z.object({ token: z.string() }).partial();
const withEmail = z.object({ email: z.string() }).partial();

const isPasswordResetPath = (ctx: HookEndpointContext) =>
  ctx.path === '/reset-password' ||
  ctx.path === '/email-otp/reset-password' ||
  ctx.path === '/phone-number/reset-password';

export function passwordExpiryPlugin(): BetterAuthPlugin {
  return {
    id: 'password-expiry',
    schema: {
      account: {
        fields: {
          resetRequired: {
            type: 'boolean',
            required: false,
            returned: false,
            input: false,
          },
          expiresAt: {
            type: 'date',
            required: false,
            returned: false,
            input: false,
          },
        },
        disableMigration: true,
      },
    },
    $ERROR_CODES: {
      PASSWORD_RESET_REQUIRED: ERROR_CODE,
    },
    hooks: {
      before: [
        {
          matcher: isPasswordResetPath,
          handler: createAuthMiddleware(async ctx => {
            const token = withToken.parse(ctx.body).token
              ?? withToken.parse(ctx.query).token;
            if (!token) return;

            const verification = await ctx.context.internalAdapter
              .findVerificationValue(`reset-password:${token}`);
            if (!verification) return;

            const accounts = await ctx.context.internalAdapter
              .findAccounts(verification.value);
            const account = accounts.find(a => a.providerId === 'credential') as CredentialAccount ?? null;

            await resetPasswordAccount.set(account);
          })
        },
        {
          matcher: (ctx: HookEndpointContext) => ctx.path === '/sign-in/email',
          handler: createAuthMiddleware(async ctx => {
            const email = withEmail.parse(ctx.body).email;
            if (!email) return;

            const record = await ctx.context.internalAdapter.findUserByEmail(email, { includeAccounts: true });
            if (!record) return;

            const credAccount = record.accounts.find(
              a => a.providerId === 'credential'
            ) as CredentialAccount | undefined;
            if (!credAccount) return;

            const passwordExpired = !!credAccount.expiresAt && new Date() > new Date(credAccount.expiresAt);

            if (credAccount.resetRequired || passwordExpired) {
              throw APIError.from('FORBIDDEN', ERROR_CODE);
            }
          })
        }
      ],
      after: [
        {
          matcher: isPasswordResetPath,
          handler: createAuthMiddleware(async ctx => {
            if (isAPIError(ctx.context.returned)) return;

            const account = await resetPasswordAccount.get();
            if (!account) return;

            await ctx.context.internalAdapter.updateAccount(account.id, {
              resetRequired: null,
              expiresAt: null,
            } as Partial<Account>);
          })
        }
      ],
    }
  };
}

<!-- gh-comment-id:3920869527 --> @DibyodyutiMondal commented on GitHub (Feb 18, 2026): I wrote a plugin that could address this issue: ```ts import { APIError } from 'better-auth'; import { isAPIError } from 'better-auth/api'; import { createAuthMiddleware } from 'better-auth/plugins'; import { defineRequestState } from '@better-auth/core/context'; import type { Account, BetterAuthPlugin, HookEndpointContext } from 'better-auth'; import { z } from 'zod'; type CredentialAccount = Account & { resetRequired?: boolean | null; expiresAt?: Date | null; }; const ERROR_CODE = { code: 'PASSWORD_RESET_REQUIRED', message: 'A password reset is required before you can sign in.', } as const; const resetPasswordAccount = defineRequestState<CredentialAccount | null>(() => null); const withToken = z.object({ token: z.string() }).partial(); const withEmail = z.object({ email: z.string() }).partial(); const isPasswordResetPath = (ctx: HookEndpointContext) => ctx.path === '/reset-password' || ctx.path === '/email-otp/reset-password' || ctx.path === '/phone-number/reset-password'; export function passwordExpiryPlugin(): BetterAuthPlugin { return { id: 'password-expiry', schema: { account: { fields: { resetRequired: { type: 'boolean', required: false, returned: false, input: false, }, expiresAt: { type: 'date', required: false, returned: false, input: false, }, }, disableMigration: true, }, }, $ERROR_CODES: { PASSWORD_RESET_REQUIRED: ERROR_CODE, }, hooks: { before: [ { matcher: isPasswordResetPath, handler: createAuthMiddleware(async ctx => { const token = withToken.parse(ctx.body).token ?? withToken.parse(ctx.query).token; if (!token) return; const verification = await ctx.context.internalAdapter .findVerificationValue(`reset-password:${token}`); if (!verification) return; const accounts = await ctx.context.internalAdapter .findAccounts(verification.value); const account = accounts.find(a => a.providerId === 'credential') as CredentialAccount ?? null; await resetPasswordAccount.set(account); }) }, { matcher: (ctx: HookEndpointContext) => ctx.path === '/sign-in/email', handler: createAuthMiddleware(async ctx => { const email = withEmail.parse(ctx.body).email; if (!email) return; const record = await ctx.context.internalAdapter.findUserByEmail(email, { includeAccounts: true }); if (!record) return; const credAccount = record.accounts.find( a => a.providerId === 'credential' ) as CredentialAccount | undefined; if (!credAccount) return; const passwordExpired = !!credAccount.expiresAt && new Date() > new Date(credAccount.expiresAt); if (credAccount.resetRequired || passwordExpired) { throw APIError.from('FORBIDDEN', ERROR_CODE); } }) } ], after: [ { matcher: isPasswordResetPath, handler: createAuthMiddleware(async ctx => { if (isAPIError(ctx.context.returned)) return; const account = await resetPasswordAccount.get(); if (!account) return; await ctx.context.internalAdapter.updateAccount(account.id, { resetRequired: null, expiresAt: null, } as Partial<Account>); }) } ], } }; } ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9149