Allow re-registration of user is not verified e-mail #1024

Closed
opened 2026-03-13 08:19:14 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @asimaranov on GitHub (Apr 11, 2025).

Is this suited for github?

  • Yes, this is suited for github

Is your feature request related to a problem? Please describe.

I use OTP codes. I have the following flow with an issue

  1. Use tries to sign up
  2. User requests an otp code but then goes back in the sign up flow to the sign up form.
  3. User tries to sign up once again but now his account is already created in database and there's error "User already exists" even if the email is not verified and user can't sign in
Image

Describe the solution you'd like

I'd suggest to add an option to allow signup when user exist in database but email is not verified

Describe alternatives you've considered

No

Additional context

No response

Originally created by @asimaranov on GitHub (Apr 11, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. I use OTP codes. I have the following flow with an issue 1. Use tries to sign up 2. User requests an otp code but then goes back in the sign up flow to the sign up form. 3. User tries to sign up once again but now his account is already created in database and there's error "User already exists" even if the email is not verified and user can't sign in <img width="472" alt="Image" src="https://github.com/user-attachments/assets/6a7abd54-f741-448c-9880-3d8c49bb6f17" /> ### Describe the solution you'd like I'd suggest to add an option to allow signup when user exist in database but email is not verified ### Describe alternatives you've considered No ### Additional context _No response_
Author
Owner

@s3f5 commented on GitHub (Apr 11, 2025):

Just handle this case and thats it:

A) If the user exist, just display a email verification flow
B) if the user exist, just push sign-in flow and send a email verification or otp again + show some info to the user

Look here => https://www.better-auth.com/docs/plugins/email-otp#send-otp

@s3f5 commented on GitHub (Apr 11, 2025): Just handle this case and thats it: A) If the user exist, just display a email verification flow B) if the user exist, just push sign-in flow and send a email verification or otp again + show some info to the user Look here => https://www.better-auth.com/docs/plugins/email-otp#send-otp
Author
Owner

@asimaranov commented on GitHub (Apr 11, 2025):

But in this case old password will be used.
There can be a vulnerability in this case, for example I use your e-mail and my password, trying to create an account. I can't verify e-mail but user is created in database. When you try to sign-up, the verification otp is sent and user is activated. But my password is used for your account

@asimaranov commented on GitHub (Apr 11, 2025): But in this case old password will be used. There can be a vulnerability in this case, for example I use your e-mail and my password, trying to create an account. I can't verify e-mail but user is created in database. When you try to sign-up, the verification otp is sent and user is activated. But my password is used for your account
Author
Owner

@s3f5 commented on GitHub (Apr 11, 2025):

But in this case old password will be used. There can be a vulnerability in this case, for example I use your e-mail and my password, trying to create an account. I can't verify e-mail but user is created in database. When you try to sign-up, the verification otp is sent and user is activated. But my password is used for your account

No? I mean, if someone tries to create an account without owning the email address to verify it, then it's their fault. Anyway, there's no vulnerability or anything like that, since the password is never leaked to the user. They can only reset the password and set a new one.

@s3f5 commented on GitHub (Apr 11, 2025): > But in this case old password will be used. There can be a vulnerability in this case, for example I use your e-mail and my password, trying to create an account. I can't verify e-mail but user is created in database. When you try to sign-up, the verification otp is sent and user is activated. But my password is used for your account No? I mean, if someone tries to create an account without owning the email address to verify it, then it's their fault. Anyway, there's no vulnerability or anything like that, since the password is never leaked to the user. They can only reset the password and set a new one.
Author
Owner

@Qodestackr commented on GitHub (Apr 13, 2025):

POST https://www.alcorabookas.com/api/auth/sign-in/email 500 (Internal Server Error)
what causes this kinda errors ?

@Qodestackr commented on GitHub (Apr 13, 2025): POST https://www.alcorabookas.com/api/auth/sign-in/email 500 (Internal Server Error) what causes this kinda errors ?
Author
Owner

@s3f5 commented on GitHub (Apr 13, 2025):

POST https://www.alcorabookas.com/api/auth/sign-in/email 500 (Internal Server Error) what causes this kinda errors ?

can u provide more context eg. payload or error log ?

@s3f5 commented on GitHub (Apr 13, 2025): > POST https://www.alcorabookas.com/api/auth/sign-in/email 500 (Internal Server Error) what causes this kinda errors ? can u provide more context eg. payload or error log ?
Author
Owner

@Bekacru commented on GitHub (May 7, 2025):

there is a lot of security implications to think about with allowing this feature. I don't think we'd ever implement it but you still can use hooks to acheive this

@Bekacru commented on GitHub (May 7, 2025): there is a lot of security implications to think about with allowing this feature. I don't think we'd ever implement it but you still can use hooks to acheive this
Author
Owner

@iaa2005 commented on GitHub (Aug 7, 2025):

!!! SOLUTION !!!

Yes, @asimaranov, I have this problem with credentials too. My user registered and not verified, and he wants to re-register again with new password (or we don't know about first registration, hacker could register with his password and user's mail address).

So, we have one decision: create own logic! Here is example: own verifying is existing, has credential, own hashing password:

// auth/auth-client.ts

import { createAuthClient } from "better-auth/react"

export const authClient = createAuthClient({
    /** The base URL of the server (optional if you're using the same domain) */
    baseURL: "http://localhost:3000"
})

export const { signIn, signUp, useSession, signOut } = authClient

export const signInWithGoogle = async () => {
    const data = await authClient.signIn.social({
        provider: "google",
        callbackURL: "/dashboard"
    })
}
// auth/auth.ts

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "@prisma/prisma";
import { nextCookies } from "better-auth/next-js";
import { sendMailFromNoReply } from "@/mail/mail";
import { confirmMailHtml } from "./email-templates";

import { hashPassword, verifyPassword } from "./password-api";

export const auth = betterAuth({
    database: prismaAdapter(prisma, {
        provider: "postgresql",
    }),
    emailAndPassword: {
        enabled: true,
        requireEmailVerification: true,
        password: {
            hash: hashPassword,
            verify: verifyPassword
        }
    },
    emailVerification: {
        sendVerificationEmail: async ( { user, url, token }, request) => {
            console.log(user.email, url);

            let _url = `${process.env.BETTER_AUTH_URL}/verify-email?token=${token}`;

            await sendMailFromNoReply({
                to: user.email,
                subject: "Verify your email address",
                text: `<p>Click the link to verify your email:</p> <a href="${url}">Link</a>`,
                html: confirmMailHtml(user.email, _url),
            });
        },
        sendOnSignIn: false,
    },
    socialProviders: {
        google: { 
            prompt: "select_account",
            clientId: process.env.GOOGLE_CLIENT_ID as string, 
            clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 
        }, 
    },
    account: {
        accountLinking: {
            enabled: true,
            autoLink: true,
        },
    },
    plugins: [nextCookies()],
});
// auth/actions.ts
// USED zod form forms, see only algorithms (not return form and error with messages)

'use server';

import { signUpFormSchema, signUpFormState, signInFormSchema, signInFormState} from "./auth-schema";
import { redirect } from "next/navigation";
import { auth } from "./auth";
import { APIError } from "better-auth/api";
import { authClient } from "./auth-client";
import { getErrorMessage } from "./error-codes";

import { isExistingCredentialUser, isVerifiedUser, updateSignUpPasswordAndName } from "@prisma/api";


export async function signUpAction(_prev: signUpFormState, formData: FormData): Promise<signUpFormState> {
    // console.log("Raw form data:", formData);

    // 1. Validate form fields
    const form = Object.fromEntries(formData);
    const validatedFields = signUpFormSchema.safeParse(form)

    // If any form fields are invalid, return early
    if (!validatedFields.success) {        
        return {
            form,
            errors: validatedFields.error.flatten().fieldErrors,
        }
    }

    // 2. Prepare data for insertion into database
    const { name, email, password } = validatedFields.data

    // 3. Insert the user into the database or call an Auth Library's API
    if (await isExistingCredentialUser(email)) {
        console.log("✅ User already exists:", email);

        if (await isVerifiedUser(email)) {
            return {
                form: {
                    email
                },
                message: "⚠️ Пользователь уже зарегистрирован с таким e-mail."
            }

        }

        // If the user exists but is not verified, update the password and resend verification email:
        // update password with own hashing logic or library:

        await updateSignUpPasswordAndName(email, password, name);

        console.log("✅ Updated password for existing user:", email);

        // resend verification email:
        try {
            await authClient.sendVerificationEmail({
                email: email,
                callbackURL: "/verify-email"
            });
        } catch (error) {
            return {
                form: {
                    email
                },
                message: "⚠️ Не удалось отправить письмо с подтверждением."
            }
        }

        return {
            form: {
                email
            },
            message: "📨 Письмо с подтверждением отправлено на ваш e-mail."
        }
    }

    console.log("✅ New user registration:", email);
    
    // 4. If the user does not exist, create a new user
    try {
        await auth.api.signUpEmail({
            body: {
                name,
                email,
                password
            },
        });
    } catch (error) {
        if (error instanceof APIError) {
    
            const { code, message } = getErrorMessage(error.body?.code as string);
    
            return {
                form,
                message: message
            }
        }
    }

    return {
        form: {
            email
        },
        message: "📨 Подтвердите регистрацию в письме."
    }
}


export async function signInAction(_prev: signInFormState, formData: FormData): Promise<signInFormState> {
    // 1. Validate form fields
    const form = Object.fromEntries(formData);
    const validatedFields = signInFormSchema.safeParse(form)

    // If any form fields are invalid, return early
    if (!validatedFields.success) {        
        return {
            form,
            errors: validatedFields.error.flatten().fieldErrors,
        }
    }
    
    // 2. Prepare data for authentication
    const { email, password } = validatedFields.data

    // 3. Authenticate the user
    try {
        await auth.api.signInEmail({
            body: {
                email,
                password
            },
        });
    } catch (error) {
        if (error instanceof APIError) {

            const { code, message } = getErrorMessage(error.body?.code as string);

            return {
                form,
                message: message
            }
        }
        
    }

    // 4. Redirect to the dashboard after successful sign-in
    redirect('/dashboard');
}
// prisma/api.ts

'use server';

import { hashPassword } from "@/auth/password-api";
import { prisma } from "./prisma";

export async function isExistingCredentialUser(email: string) {
    const account = await prisma.account.findFirst({
        where: {
            user: {
                email
            },
            providerId: "credential"
        }
    });

    if (account !== null) {
        return true;
    }

    return false;
}

export async function isVerifiedUser(email: string) {
    const user = await prisma.user.findUnique({
        where: {
            email
        }
    });

    return user?.emailVerified === true;
}


export async function updateSignUpPasswordAndName(email: string, password: string, name: string) {
    const hashedPassword = await hashPassword(password);

    // Update password in the Account model with providerId "credentials" 
    const account = await prisma.account.findFirst({
        where: {
            user: {
                email
            },
            providerId: "credential"
        }
    });

    if (!account) {
        throw new Error("Account not found for the provided email.");
    }

    await prisma.account.update({
        where: {
            id: account.id
        },
        data: {
            password: hashedPassword
        }
    });

    // Update user name
    await prisma.user.update({
        where: {
            email
        },
        data: {
            name
        }
    });

    console.log("✅ Updated password and name for user:", email);
}
// auth/password-api.ts

'use server';

import crypto from "crypto";

const SALT_LENGTH = 32;
const HASH_LENGTH = 64;
const ITERATIONS = 100000;

export async function hashPassword(password: string): Promise<string> {
    return new Promise((resolve, reject) => {
        // Генерируем случайную соль
        const salt = crypto.randomBytes(SALT_LENGTH);
        
        // Хешируем пароль с солью
        crypto.pbkdf2(password, salt, ITERATIONS, HASH_LENGTH, 'sha256', (err, derivedKey) => {
            if (err) reject(err);
            
            // Объединяем соль и хеш в одну строку
            const hash = Buffer.concat([salt, derivedKey]).toString('hex');
            resolve(hash);
        });
    });
}

export async function verifyPassword(data: { password: string; hash: string; }): Promise<boolean> {
    return new Promise((resolve, reject) => {
        // Извлекаем соль из сохраненного хеша
        const hashBuffer = Buffer.from(data.hash, 'hex');
        const salt = hashBuffer.subarray(0, SALT_LENGTH);
        const storedHash = hashBuffer.subarray(SALT_LENGTH);
        
        // Хешируем введенный пароль с той же солью
        crypto.pbkdf2(data.password, salt, ITERATIONS, HASH_LENGTH, 'sha256', (err, derivedKey) => {
            if (err) reject(err);
            
            // Сравниваем хеши
            const isValid = crypto.timingSafeEqual(storedHash, derivedKey);
            resolve(isValid);
        });
    });
}
// auth/error-codes.ts
// See manual https://www.better-auth.com/docs/concepts/client#error-codes


import { authClient } from "./auth-client";

type ErrorTypes = Partial<
    Record<
        keyof typeof authClient.$ERROR_CODES, string
    >
>;
 
const errorCodes = {
    USER_ALREADY_EXISTS: "Пользователь с таким e-mail уже существует.",
    INVALID_EMAIL_OR_PASSWORD: "Неверный e-mail или пароль.",
    EMAIL_NOT_VERIFIED: "Пожалуйста, подтвердите свой e-mail адрес в письме. Если письмо не пришло, проверьте папку со спамом или зарегистрируйтесь заново.",
} satisfies ErrorTypes;
 
export const getErrorMessage = (code: string) => {
    if (code in errorCodes) {
        return {
            code: code,
            message: errorCodes[code as keyof typeof errorCodes],
        };
    }
    return {
        code: code,
        message: code
    };
};

@iaa2005 commented on GitHub (Aug 7, 2025): **!!! SOLUTION !!!** _Yes, @asimaranov, I have this problem with credentials too. My user registered and not verified, and he wants to re-register again with new password (or we don't know about first registration, hacker could register with his password and user's mail address)._ _So, we have one decision: create own logic! Here is example: own verifying is existing, has credential, own hashing password:_ ``` // auth/auth-client.ts import { createAuthClient } from "better-auth/react" export const authClient = createAuthClient({ /** The base URL of the server (optional if you're using the same domain) */ baseURL: "http://localhost:3000" }) export const { signIn, signUp, useSession, signOut } = authClient export const signInWithGoogle = async () => { const data = await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" }) } ``` ``` // auth/auth.ts import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { prisma } from "@prisma/prisma"; import { nextCookies } from "better-auth/next-js"; import { sendMailFromNoReply } from "@/mail/mail"; import { confirmMailHtml } from "./email-templates"; import { hashPassword, verifyPassword } from "./password-api"; export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), emailAndPassword: { enabled: true, requireEmailVerification: true, password: { hash: hashPassword, verify: verifyPassword } }, emailVerification: { sendVerificationEmail: async ( { user, url, token }, request) => { console.log(user.email, url); let _url = `${process.env.BETTER_AUTH_URL}/verify-email?token=${token}`; await sendMailFromNoReply({ to: user.email, subject: "Verify your email address", text: `<p>Click the link to verify your email:</p> <a href="${url}">Link</a>`, html: confirmMailHtml(user.email, _url), }); }, sendOnSignIn: false, }, socialProviders: { google: { prompt: "select_account", clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, account: { accountLinking: { enabled: true, autoLink: true, }, }, plugins: [nextCookies()], }); ``` ``` // auth/actions.ts // USED zod form forms, see only algorithms (not return form and error with messages) 'use server'; import { signUpFormSchema, signUpFormState, signInFormSchema, signInFormState} from "./auth-schema"; import { redirect } from "next/navigation"; import { auth } from "./auth"; import { APIError } from "better-auth/api"; import { authClient } from "./auth-client"; import { getErrorMessage } from "./error-codes"; import { isExistingCredentialUser, isVerifiedUser, updateSignUpPasswordAndName } from "@prisma/api"; export async function signUpAction(_prev: signUpFormState, formData: FormData): Promise<signUpFormState> { // console.log("Raw form data:", formData); // 1. Validate form fields const form = Object.fromEntries(formData); const validatedFields = signUpFormSchema.safeParse(form) // If any form fields are invalid, return early if (!validatedFields.success) { return { form, errors: validatedFields.error.flatten().fieldErrors, } } // 2. Prepare data for insertion into database const { name, email, password } = validatedFields.data // 3. Insert the user into the database or call an Auth Library's API if (await isExistingCredentialUser(email)) { console.log("✅ User already exists:", email); if (await isVerifiedUser(email)) { return { form: { email }, message: "⚠️ Пользователь уже зарегистрирован с таким e-mail." } } // If the user exists but is not verified, update the password and resend verification email: // update password with own hashing logic or library: await updateSignUpPasswordAndName(email, password, name); console.log("✅ Updated password for existing user:", email); // resend verification email: try { await authClient.sendVerificationEmail({ email: email, callbackURL: "/verify-email" }); } catch (error) { return { form: { email }, message: "⚠️ Не удалось отправить письмо с подтверждением." } } return { form: { email }, message: "📨 Письмо с подтверждением отправлено на ваш e-mail." } } console.log("✅ New user registration:", email); // 4. If the user does not exist, create a new user try { await auth.api.signUpEmail({ body: { name, email, password }, }); } catch (error) { if (error instanceof APIError) { const { code, message } = getErrorMessage(error.body?.code as string); return { form, message: message } } } return { form: { email }, message: "📨 Подтвердите регистрацию в письме." } } export async function signInAction(_prev: signInFormState, formData: FormData): Promise<signInFormState> { // 1. Validate form fields const form = Object.fromEntries(formData); const validatedFields = signInFormSchema.safeParse(form) // If any form fields are invalid, return early if (!validatedFields.success) { return { form, errors: validatedFields.error.flatten().fieldErrors, } } // 2. Prepare data for authentication const { email, password } = validatedFields.data // 3. Authenticate the user try { await auth.api.signInEmail({ body: { email, password }, }); } catch (error) { if (error instanceof APIError) { const { code, message } = getErrorMessage(error.body?.code as string); return { form, message: message } } } // 4. Redirect to the dashboard after successful sign-in redirect('/dashboard'); } ``` ``` // prisma/api.ts 'use server'; import { hashPassword } from "@/auth/password-api"; import { prisma } from "./prisma"; export async function isExistingCredentialUser(email: string) { const account = await prisma.account.findFirst({ where: { user: { email }, providerId: "credential" } }); if (account !== null) { return true; } return false; } export async function isVerifiedUser(email: string) { const user = await prisma.user.findUnique({ where: { email } }); return user?.emailVerified === true; } export async function updateSignUpPasswordAndName(email: string, password: string, name: string) { const hashedPassword = await hashPassword(password); // Update password in the Account model with providerId "credentials" const account = await prisma.account.findFirst({ where: { user: { email }, providerId: "credential" } }); if (!account) { throw new Error("Account not found for the provided email."); } await prisma.account.update({ where: { id: account.id }, data: { password: hashedPassword } }); // Update user name await prisma.user.update({ where: { email }, data: { name } }); console.log("✅ Updated password and name for user:", email); } ``` ``` // auth/password-api.ts 'use server'; import crypto from "crypto"; const SALT_LENGTH = 32; const HASH_LENGTH = 64; const ITERATIONS = 100000; export async function hashPassword(password: string): Promise<string> { return new Promise((resolve, reject) => { // Генерируем случайную соль const salt = crypto.randomBytes(SALT_LENGTH); // Хешируем пароль с солью crypto.pbkdf2(password, salt, ITERATIONS, HASH_LENGTH, 'sha256', (err, derivedKey) => { if (err) reject(err); // Объединяем соль и хеш в одну строку const hash = Buffer.concat([salt, derivedKey]).toString('hex'); resolve(hash); }); }); } export async function verifyPassword(data: { password: string; hash: string; }): Promise<boolean> { return new Promise((resolve, reject) => { // Извлекаем соль из сохраненного хеша const hashBuffer = Buffer.from(data.hash, 'hex'); const salt = hashBuffer.subarray(0, SALT_LENGTH); const storedHash = hashBuffer.subarray(SALT_LENGTH); // Хешируем введенный пароль с той же солью crypto.pbkdf2(data.password, salt, ITERATIONS, HASH_LENGTH, 'sha256', (err, derivedKey) => { if (err) reject(err); // Сравниваем хеши const isValid = crypto.timingSafeEqual(storedHash, derivedKey); resolve(isValid); }); }); } ``` ``` // auth/error-codes.ts // See manual https://www.better-auth.com/docs/concepts/client#error-codes import { authClient } from "./auth-client"; type ErrorTypes = Partial< Record< keyof typeof authClient.$ERROR_CODES, string > >; const errorCodes = { USER_ALREADY_EXISTS: "Пользователь с таким e-mail уже существует.", INVALID_EMAIL_OR_PASSWORD: "Неверный e-mail или пароль.", EMAIL_NOT_VERIFIED: "Пожалуйста, подтвердите свой e-mail адрес в письме. Если письмо не пришло, проверьте папку со спамом или зарегистрируйтесь заново.", } satisfies ErrorTypes; export const getErrorMessage = (code: string) => { if (code in errorCodes) { return { code: code, message: errorCodes[code as keyof typeof errorCodes], }; } return { code: code, message: code }; }; ```
Author
Owner

@wmodden commented on GitHub (Sep 17, 2025):

what about an option to only create the user on the db when the user is verified? (kinda what clerk does)

@wmodden commented on GitHub (Sep 17, 2025): what about an option to only create the user on the db when the user is verified? (kinda what clerk does)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1024