Preventing Browser Password Saving for Vue 3 Login via Text Input Masking

Browser default behavior monitors form submissions and prompts to save passwords when a visible type="password" input is detected. Too bypass this, use a text input instead and dynamically replace entered characters with dots.

<template>
    <el-form :model="authCreds" :rules="authRules" ref="authFormRef">
        <el-form-item prop="email">
            <el-input type="text" placeholder="Email Address" v-model="authCreds.email" />
        </el-form-item>
        <el-form-item prop="maskedPwd">
            <el-input
                type="text"
                placeholder="Secure Password"
                v-model="authCreds.maskedPwd"
                @input="maskInput"
                @select="trackSelection"
                @keyup.delete="handleDelete"
                @copy.prevent
                @cut.prevent
            />
        </el-form-item>
        <el-form-item>
            <el-button :loading="isSubmitting" @click="submitAuth" type="primary">
                <span v-if="!isSubmitting">Sign In</span>
                <span v-else>Authenticating...</span>
            </el-button>
        </el-form-item>
    </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import 'element-plus/dist/index.css'

const authFormRef = ref(null)
const isSubmitting = ref(false)
const hasSelection = ref(false)
const selectionRange = ref({ start: 0, end: 0 })

const authCreds = reactive({
    email: '',
    maskedPwd: '',
    realPwd: '',
    lastMasked: ''
})

const authRules = {
    email: [{ required: true, message: 'Please enter your email', trigger: 'blur' }],
    maskedPwd: [{ required: true, message: 'Please enter your password', trigger: 'blur' }]
}

const trackSelection = (e) => {
    hasSelection.value = true
    selectionRange.value = {
        start: e.target.selectionStart,
        end: e.target.selectionEnd
    }
}

const handleDelete = (e) => {
    const { realPwd, maskedPwd } = authCreds
    const { start, end } = e.target.selection
    const deleteCount = realPwd.length - maskedPwd.length

    if (deleteCount === 1) {
        authCreds.realPwd = realPwd.substring(0, start) + realPwd.substring(end + 1)
    } else if (deleteCount > 1) {
        authCreds.realPwd = hasSelection.value
            ? realPwd.substring(0, selectionRange.value.start) + realPwd.substring(selectionRange.value.end)
            : realPwd.substring(0, realPwd.length - deleteCount)
    }

    authCreds.maskedPwd = '●'.repeat(authCreds.realPwd.length)
    authCreds.lastMasked = authCreds.maskedPwd
    hasSelection.value = false
    selectionRange.value = { start: 0, end: 0 }
}

const maskInput = (val) => {
    const chinesePattern = /[\u4E00-\u9FA5]/
    const chinesePuncPattern = /[。?!,、;:""‘’()《》〈〉【】『』「」﹃﹄〔〕…—~﹏¥]/

    if (chinesePattern.test(val) || chinesePuncPattern.test(val)) {
        authCreds.maskedPwd = authCreds.lastMasked
        ElMessage.error('Only English characters and standard symbols are allowed')
        return
    }

    const unmaskedPart = val.replaceAll('●', '')
    if (!unmaskedPart) return

    const valArray = Array.from(val)
    const { realPwd, lastMasked } = authCreds
    const lastMaskedLength = lastMasked.length

    if (!lastMaskedLength) {
        authCreds.realPwd = unmaskedPart
    } else {
        let startDotCount = 0, endDotCount = 0
        let startInputIndex = -1, endInputIndex = -1

        for (let i = 0; i < valArray.length; i++) {
            if (valArray[i] === '●' && startInputIndex === -1) {
                startDotCount++
            } else if (valArray[i] !== '●' && startInputIndex === -1) {
                startInputIndex = i
            } else if (valArray[i] === '●' && startInputIndex !== -1 && endInputIndex === -1) {
                endInputIndex = i
                endDotCount++
            } else if (valArray[i] === '●' && endInputIndex !== -1) {
                endDotCount++
            }
        }

        if (!startDotCount && !endDotCount) {
            authCreds.realPwd = unmaskedPart
        } else if (startDotCount && endDotCount) {
            authCreds.realPwd = realPwd.substring(0, startDotCount) +
                val.substring(startDotCount, val.length - endDotCount) +
                realPwd.substring(lastMaskedLength - endDotCount)
        } else if (startDotCount) {
            authCreds.realPwd = realPwd.substring(0, startDotCount) + val.substring(startDotCount)
        } else if (endDotCount) {
            authCreds.realPwd = val.substring(0, val.length - endDotCount) +
                realPwd.substring(lastMaskedLength - endDotCount, lastMaskedLength)
        }
    }

    authCreds.maskedPwd = '●'.repeat(authCreds.realPwd.length)
    authCreds.lastMasked = authCreds.maskedPwd
}

const submitAuth = async () => {
    const valid = await authFormRef.value?.validate()
    if (!valid) return

    isSubmitting.value = true
    try {
        console.log('Authenticating with:', {
            email: authCreds.email,
            password: authCreds.realPwd
        })
        await new Promise(resolve => setTimeout(resolve, 2000))
        ElMessage.success('Sign in successful')
    } catch (error) {
        ElMessage.error('Sign in failed. Please check credentials.')
    } finally {
        isSubmitting.value = false
    }
}
</script>

Key logic explanations:

  • @input triggers charatcer masking and real password reconstruction
  • @select tracks text selection for accurate deletion/replacement handling
  • @keyup.delete handles deletion events across various selection scenarios
  • @copy.prevent and @cut.prevent disable clipboard operations for masked content

Tags: Vue 3 Element Plus browser security form handling User Authentication

Posted on Tue, 07 Jul 2026 16:25:06 +0000 by nloding