docs/content/docs/concepts/users-accounts.mdx
Beyond authenticating users, Better Auth also provides a set of methods to manage users. This includes, updating user information, changing passwords, and more.
The user table stores the authentication data of the user Click here to view the schema.
The user table can be extended using additional fields or by plugins to store additional data.
To update user information, you can use the updateUser function provided by the client. The updateUser function takes an object with the following properties:
import { authClient } from "@/lib/auth-client"
await authClient.updateUser({
image: "https://example.com/image.jpg",
name: "John Doe",
})
To allow users to change their email, first enable the changeEmail feature, which is disabled by default. Set changeEmail.enabled to true:
import { betterAuth } from "better-auth";
import { sendEmail } from './email'; // your email sending function
export const auth = betterAuth({
user: {
changeEmail: {
enabled: true,
}
},
emailVerification: {
// Required to send the verification email
sendVerificationEmail: async ({ user, url, token }) => {
void sendEmail({
to: user.email,
})
}
}
})
By default, when a user requests to change their email, a verification email is sent to the new email address. The email is only updated after the user verifies the new email.
For added security, you can require users to confirm the change via their current email before
the verification email is sent to the new address. To do this, provide the sendChangeEmailConfirmation function.
import { betterAuth } from "better-auth";
import { sendEmail } from './email'; // your email sending function
export const auth = betterAuth({
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation: async ({ user, newEmail, url, token }, request) => {
void sendEmail({
to: user.email, // Sent to the CURRENT email
subject: 'Approve email change',
text: `Click the link to approve the change to ${newEmail}: ${url}`
})
}
}
},
// ...
})
If you want to allow users to update their email immediately without verification (only if their current email is NOT verified), you can enable updateEmailWithoutVerification.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
changeEmail: {
enabled: true,
updateEmailWithoutVerification: true
}
}
})
Use the changeEmail function on the client to initiate the process.
import { authClient } from "@/lib/auth-client"
await authClient.changeEmail({
newEmail: "[email protected]",
callbackURL: "/dashboard", // to redirect after verification
});
A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches:
<APIMethod path="/change-password" method="POST" requireSession> ```ts type changePassword = { /** * The new password to set */ newPassword: string = "newpassword1234" /** * The current user password */ currentPassword: string = "oldpassword1234" /** * When set to true, all other active sessions for this user will be invalidated */ revokeOtherSessions?: boolean = true } ``` </APIMethod>If a user was registered using OAuth or other providers, they won't have a password or a credential account. In this case, you can use the setPassword action to set a password for the user. For security reasons, this function can only be called from the server. We recommend having users go through a 'forgot password' flow to set a password for their account.
import { auth } from "@/lib/auth"
await auth.api.setPassword({
body: {
newPassword: "new-password",
},
headers: await headers() // headers containing the user's session token
});
The verifyPassword function allows you to verify a user's current password. This is useful for confirming user identity before performing sensitive operations like updating security settings. This function can only be called from the server.
import { auth } from "@/lib/auth"
await auth.api.verifyPassword({
body: {
password: "user-password" // required
},
headers: await headers() // headers containing the user's session token
});
Better Auth provides a utility to hard delete a user from your database. It's disabled by default, but you can enable it easily by passing enabled:true
import { betterAuth } from "better-auth";
export const auth = betterAuth({
//...other config
user: {
deleteUser: { // [!code highlight]
enabled: true // [!code highlight]
} // [!code highlight]
}
})
Once enabled, you can call authClient.deleteUser to permanently delete user data from your database.
For added security, you’ll likely want to confirm the user’s intent before deleting their account. A common approach is to send a verification email. Better Auth provides a sendDeleteAccountVerification utility for this purpose.
This is especially needed if you have OAuth setup and want them to be able to delete their account without forcing them to login again for a fresh session.
Here’s how you can set it up:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
deleteUser: {
enabled: true,
sendDeleteAccountVerification: async (
{
user, // The user object
url, // The auto-generated URL for deletion
token // The verification token (can be used to generate custom URL)
},
request // The original request object (optional)
) => {
// Your email sending logic here
// Example: sendEmail(data.user.email, "Verify Deletion", data.url);
},
},
},
});
How callback verification works:
sendDeleteAccountVerification is a pre-generated link that deletes the user data when accessed.import { authClient } from "@/lib/auth-client"
await authClient.deleteUser({
callbackURL: "/goodbye" // you can provide a callback URL to redirect after deletion
});
If you have sent a custom URL, you can use the deleteUser method with the token to delete the user.
import { authClient } from "@/lib/auth-client"
await authClient.deleteUser({
token
});
To delete a user, the user must meet one of the following requirements:
if the user has a password, they can delete their account by providing the password.
import { authClient } from "@/lib/auth-client"
await authClient.deleteUser({
password: "password"
});
The user must have a fresh session token, meaning the user must have signed in recently. This is checked if the password is not provided.
import { authClient } from "@/lib/auth-client"
await authClient.deleteUser();
As OAuth users don't have a password, we need to send a verification email to confirm the user's intent to delete their account. If you have already added the sendDeleteAccountVerification callback, you can just call the deleteUser method without providing any other information.
import { authClient } from "@/lib/auth-client"
await authClient.deleteUser();
sendDeleteAccountVerification callback.
Then you need to call the deleteUser method with the token to complete the deletion.import { authClient } from "@/lib/auth-client"
await authClient.deleteUser({
token
});
validateUserInfo: A gate that decides which identities Better Auth admits. It fires just before a user is created (create-user) or a new provider account is linked (link-account), for every authentication method (OAuth, OIDC SSO, SAML SSO, email/password, magic link, email OTP, anonymous, SIWE, phone number, admin-created users, and SCIM), including stateless setups with no persistent database, so policy lives in one place instead of per provider.
It also fires when an existing OAuth or SSO user signs in again (sign-in), and there it receives the fresh provider email and profile rather than the stored row. That lets a domain or org policy reject a user whose provider identity moved out of bounds (for example, an email that left the allowed domain). Non-provider returning sign-ins are not re-validated, because their stored row has not changed since create-user gated it; use the admin plugin's ban controls or a databaseHooks.session.create.before hook to block those.
source.action is "create-user", "link-account", or "sign-in", and source.method is the authentication method. For OAuth, source.oauth carries the provider id and raw provider profile. For OIDC and SAML SSO, source.sso carries the SSO provider id and raw provider claims or assertion attributes.
Return nothing to allow provisioning. Return an object with error to reject it: browser/redirect flows send the rejection to the configured error URL, while programmatic flows return a 403 API error. Avoid putting sensitive details in errorDescription because it is returned to the client.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
validateUserInfo: ({ user, source }) => {
if (!user.email?.endsWith("@example.com")) {
return {
error: "email_not_allowed",
errorDescription: "Use your example.com email to sign in",
};
}
if (
source.oauth?.providerId === "company-oauth" &&
source.oauth?.profile?.hd !== "example.com"
) {
return {
error: "invalid_organization",
errorDescription: "Use your company OAuth account",
};
}
},
},
});
beforeDelete: This callback is called before the user is deleted. You can use this callback to perform any cleanup or additional checks before deleting the user.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
// Perform any cleanup or additional checks here
},
},
},
});
you can also throw APIError to interrupt the deletion process.
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
export const auth = betterAuth({
user: {
deleteUser: {
enabled: true,
beforeDelete: async (user, request) => {
if (user.email.includes("admin")) {
throw new APIError("BAD_REQUEST", {
message: "Admin accounts can't be deleted",
});
}
},
},
},
});
afterDelete: This callback is called after the user is deleted. You can use this callback to perform any cleanup or additional actions after the user is deleted.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
deleteUser: {
enabled: true,
afterDelete: async (user, request) => {
// Perform any cleanup or additional actions here
},
},
},
});
Better Auth supports multiple authentication methods through providers such as email and password, Google, or an enterprise identity provider. Each method linked to a user is stored as an account.
An account has a local record ID and a provider identity. id identifies the Better Auth account record and is the value to pass as accountId to account-management APIs. The pair of issuer and accountId identifies the external account: issuer names the trusted authority, and accountId is the stable identifier that authority assigned. The providerId identifies the provider configuration Better Auth uses for protocol operations.
OAuth providers without a trusted issuer use local:oauth:<encoded providerId> as their account namespace, with the provider ID segment percent-encoded. Credential accounts use local:credential; do not use that credential namespace for an OAuth provider.
This separation allows multiple provider configurations for the same issuer to deduplicate the same external identity without treating an identifier from another issuer as the same person. Provider aliases share one account row and token set; they do not have independent grants or provider lifecycle records. See the account schema for the complete set of fields.
Use listAccounts to retrieve every authentication method linked to the current user. Keep the returned id when you need to unlink the account or call another account-specific API.
import { authClient } from "@/lib/auth-client"
const { data: accounts, error } = await authClient.listAccounts();
if (error) {
throw new Error(error.message);
}
const googleAccount = accounts?.find((account) => account.providerId === "google");
Better Auth doesn’t encrypt tokens by default and that’s intentional. We want you to have full control over how encryption and decryption are handled, rather than baking in behavior that could be confusing or limiting. If you need to store encrypted tokens (like accessToken or refreshToken), you can use databaseHooks to encrypt them before they’re saved to your database.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
databaseHooks: {
account: {
create: {
before(account, context) {
const withEncryptedTokens = { ...account };
if (account.accessToken) {
const encryptedAccessToken = encrypt(account.accessToken) // [!code highlight]
withEncryptedTokens.accessToken = encryptedAccessToken;
}
if (account.refreshToken) {
const encryptedRefreshToken = encrypt(account.refreshToken); // [!code highlight]
withEncryptedTokens.refreshToken = encryptedRefreshToken;
}
return {
data: withEncryptedTokens
}
},
}
}
}
})
Then whenever you retrieve back the account make sure to decrypt the tokens before using them.
Account linking is enabled by default and lets users associate multiple authentication methods with a single account. With Better Auth, users can connect additional social sign-ons or OAuth providers to their existing accounts if the provider confirms the user's email as verified.
If account linking is disabled, no accounts can be linked, regardless of the provider or email verification status.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
enabled: false,
}
},
});
You can specify a list of "trusted providers." When a user logs in using a trusted provider, their account will be automatically linked even if the provider doesn’t confirm the email verification status. Use this with caution as it may increase the risk of account takeover.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"]
}
},
});
By default, when a user signs in with an OAuth provider whose email matches an existing user (and either the provider verified the email or it is in trustedProviders), Better Auth automatically links the OAuth account to that user. Set disableImplicitLinking: true to turn this off. With this option enabled:
account_not_linked error instead of being silently linked, even when the provider is in trustedProviders or the email is verified.linkSocial().Use this when you want users to confirm linking from a settings page rather than implicitly on sign-in.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
disableImplicitLinking: true,
}
},
});
Users already signed in can manually link their account to additional social providers or credential-based accounts.
Linking Social Accounts: Use the linkSocial method on the client to link a social provider to the user's account.
import { authClient } from "@/lib/auth-client"
await authClient.linkSocial({
provider: "google", // Provider to link
callbackURL: "/callback" // Callback URL after linking completes
});
You can also request specific scopes when linking a social account, which can be different from the scopes used during the initial authentication:
import { authClient } from "@/lib/auth-client"
await authClient.linkSocial({
provider: "google",
callbackURL: "/callback",
scopes: ["https://www.googleapis.com/auth/drive.readonly"] // Request additional scopes
});
You can also link accounts using ID tokens directly, without redirecting to the provider's OAuth flow:
import { authClient } from "@/lib/auth-client"
await authClient.linkSocial({
provider: "google",
idToken: {
token: "id_token_from_provider",
nonce: "nonce_used_for_token", // Optional
accessToken: "access_token", // Optional, may be required by some providers
refreshToken: "refresh_token" // Optional
}
});
This is useful when you already have valid tokens from the provider, for example:
The ID token must be valid and the provider must support ID token verification.
If you want your users to be able to link a social account with a different email address than the user, or if you want to use a provider that does not return email addresses, you will need to enable this in the account linking settings.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
allowDifferentEmails: true
}
},
});
By default, linking an account leaves the existing user profile untouched. Enable updateUserInfoOnLink to copy the provider's profile onto the user each time an account is linked. The synced fields are the same ones persisted on sign-up (name, image, and any input-allowed fields your mapProfileToUser adds). The user's email and emailVerified are never changed on a link, so linking a provider can't rebind the account's identity.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
updateUserInfoOnLink: true
}
},
});
Linking Credential-Based Accounts: To link a credential-based account (e.g., email and password), users can initiate a "forgot password" flow, or you can call the setPassword method on the server.
import { auth } from "@/lib/auth"
await auth.api.setPassword({
body: {
newPassword: "new-password", // required
},
headers: await headers() // headers containing the user's session token
});
Unlink an account by passing the Better Auth account record's id, which you can obtain from listAccounts.
import { authClient } from "@/lib/auth-client"
const { data: accounts, error } = await authClient.listAccounts();
if (error) {
throw new Error(error.message);
}
const account = accounts?.find((account) => account.providerId === "google");
if (account) {
await authClient.unlinkAccount({
accountId: account.id,
});
}
If the account does not exist or does not belong to the current user, Better Auth returns an error. Better Auth also prevents a user from unlinking their only account unless allowUnlinkingAll is true.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
accountLinking: {
allowUnlinkingAll: true
}
},
});