Skip to main content
CodeRocket
Securityhighforms

Secure password input fields

rule · password-field-security

Secure password fields balance security with usability while supporting password managers and accessibility.

Code Examples

HTML
<!-- Login form -->
<form method="POST" action="/login">
  <div>
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      required
    />
  </div>
 
  <div>
    <label for="password">Password</label>
    <input
      type="password"
      id="password"
      name="password"
      autocomplete="current-password"
      required
      minlength="8"
    />
  </div>
 
  <button type="submit">Sign In</button>
</form>
HTML
<!-- Registration form -->
<form method="POST" action="/register">
  <div>
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      required
    />
  </div>
 
  <div>
    <label for="new-password">Create Password</label>
    <input
      type="password"
      id="new-password"
      name="password"
      autocomplete="new-password"
      required
      minlength="12"
      aria-describedby="password-requirements"
    />
    <p id="password-requirements">
      At least 12 characters with uppercase, lowercase, and numbers.
    </p>
  </div>
 
  <button type="submit">Create Account</button>
</form>

Why It Matters

Properly implemented password fields improve security by working with password managers, helping users create strong passwords, and providing accessible controls for all users.

Autocomplete Attributes

ScenarioAttributePurpose
Login formautocomplete="current-password"Fill existing password
Registrationautocomplete="new-password"Suggest strong password
Usernameautocomplete="username"Associate with password
Email loginautocomplete="email"For email-based auth

React Password Field with Toggle

TSX
import { useState, useId } from 'react'
 
interface PasswordFieldProps {
  label: string
  name: string
  autocomplete: 'current-password' | 'new-password'
  value: string
  onChange: (value: string) => void
  error?: string
  minLength?: number
}
 
export function PasswordField({
  label,
  name,
  autocomplete,
  value,
  onChange,
  error,
  minLength = 8,
}: PasswordFieldProps) {
  const [showPassword, setShowPassword] = useState(false)
  const inputId = useId()
  const errorId = useId()
 
  return (
    <div className="password-field">
      <label htmlFor={inputId} className="password-label">
        {label}
      </label>
 
      <div className="password-input-wrapper">
        <input
          type={showPassword ? 'text' : 'password'}
          id={inputId}
          name={name}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          autoComplete={autocomplete}
          minLength={minLength}
          required
          aria-invalid={error ? 'true' : 'false'}
          aria-describedby={error ? errorId : undefined}
          className="password-input"
        />
 
        <button
          type="button"
          onClick={() => setShowPassword(!showPassword)}
          aria-label={showPassword ? 'Hide password' : 'Show password'}
          aria-pressed={showPassword}
          className="password-toggle"
        >
          {showPassword ? (
            <EyeOffIcon aria-hidden="true" />
          ) : (
            <EyeIcon aria-hidden="true" />
          )}
        </button>
      </div>
 
      {error && (
        <p id={errorId} role="alert" className="password-error">
          {error}
        </p>
      )}
    </div>
  )
}
 
function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" width={20} height={20} {...props}>
      <path
        fill="currentColor"
        d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"
      />
    </svg>
  )
}
 
function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" width={20} height={20} {...props}>
      <path
        fill="currentColor"
        d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"
      />
    </svg>
  )
}

Password Strength Indicator

TSX
interface PasswordStrengthProps {
  password: string
}
 
interface StrengthResult {
  score: 0 | 1 | 2 | 3 | 4
  label: string
  requirements: {
    met: boolean
    text: string
  }[]
}
 
function calculateStrength(password: string): StrengthResult {
  const requirements = [
    {
      met: password.length >= 12,
      text: 'At least 12 characters',
    },
    {
      met: /[a-z]/.test(password),
      text: 'One lowercase letter',
    },
    {
      met: /[A-Z]/.test(password),
      text: 'One uppercase letter',
    },
    {
      met: /[0-9]/.test(password),
      text: 'One number',
    },
    {
      met: /[^a-zA-Z0-9]/.test(password),
      text: 'One special character',
    },
  ]
 
  const metCount = requirements.filter((r) => r.met).length
 
  const scoreMap: Record<number, { score: StrengthResult['score']; label: string }> = {
    0: { score: 0, label: 'Very weak' },
    1: { score: 1, label: 'Weak' },
    2: { score: 1, label: 'Weak' },
    3: { score: 2, label: 'Fair' },
    4: { score: 3, label: 'Good' },
    5: { score: 4, label: 'Strong' },
  }
 
  return {
    ...scoreMap[metCount],
    requirements,
  }
}
 
export function PasswordStrength({ password }: PasswordStrengthProps) {
  const strength = calculateStrength(password)
 
  if (!password) return null
 
  return (
    <div className="password-strength" aria-live="polite">
      <div className="strength-bar">
        <div
          className={`strength-fill strength-${strength.score}`}
          style={{ width: `${(strength.score + 1) * 20}%` }}
        />
      </div>
 
      <span className="strength-label">{strength.label}</span>
 
      <ul className="strength-requirements">
        {strength.requirements.map((req, index) => (
          <li key={index} className={req.met ? 'met' : 'unmet'}>
            <span aria-hidden="true">{req.met ? '✓' : '○'}</span>
            <span className={req.met ? 'sr-only' : undefined}>
              {req.met ? 'Complete: ' : 'Incomplete: '}
            </span>
            {req.text}
          </li>
        ))}
      </ul>
    </div>
  )
}

Complete Registration Form

TSX
'use client'
 
import { useState } from 'react'
import { PasswordField } from './password-field'
import { PasswordStrength } from './password-strength'
 
export function RegistrationForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [confirmPassword, setConfirmPassword] = useState('')
  const [errors, setErrors] = useState<Record<string, string>>({})
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
 
    const newErrors: Record<string, string> = {}
 
    if (password.length < 12) {
      newErrors.password = 'Password must be at least 12 characters'
    }
 
    if (password !== confirmPassword) {
      newErrors.confirmPassword = 'Passwords do not match'
    }
 
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }
 
    // Submit form
    // await register({ email, password })
  }
 
  return (
    <form onSubmit={handleSubmit} className="registration-form">
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          autoComplete="email"
          required
        />
      </div>
 
      <div className="form-group">
        <PasswordField
          label="Create Password"
          name="password"
          autocomplete="new-password"
          value={password}
          onChange={setPassword}
          error={errors.password}
          minLength={12}
        />
        <PasswordStrength password={password} />
      </div>
 
      <div className="form-group">
        <PasswordField
          label="Confirm Password"
          name="confirmPassword"
          autocomplete="new-password"
          value={confirmPassword}
          onChange={setConfirmPassword}
          error={errors.confirmPassword}
          minLength={12}
        />
      </div>
 
      <button type="submit" className="submit-button">
        Create Account
      </button>
    </form>
  )
}

Styling

CSS
.password-field {
  margin-bottom: 1rem;
}
 
.password-label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}
 
.password-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}
 
.password-input {
  width: 100%;
  padding: 0.75rem 3rem 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}
 
.password-input:focus {
  outline: none;
  border-color: #0066cc;
  box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
 
.password-input[aria-invalid='true'] {
  border-color: #dc2626;
}
 
.password-toggle {
  position: absolute;
  right: 0.75rem;
  padding: 0.25rem;
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
}
 
.password-toggle:hover {
  color: #333;
}
 
.password-toggle:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  border-radius: 2px;
}
 
.password-error {
  margin-top: 0.5rem;
  color: #dc2626;
  font-size: 0.875rem;
}
 
/* Strength indicator */
.password-strength {
  margin-top: 0.75rem;
}
 
.strength-bar {
  height: 4px;
  background: #e5e5e5;
  border-radius: 2px;
  overflow: hidden;
}
 
.strength-fill {
  height: 100%;
  transition: width 0.3s, background-color 0.3s;
}
 
.strength-0 { background: #dc2626; }
.strength-1 { background: #f97316; }
.strength-2 { background: #eab308; }
.strength-3 { background: #84cc16; }
.strength-4 { background: #22c55e; }
 
.strength-label {
  display: block;
  margin-top: 0.25rem;
  font-size: 0.75rem;
  color: #666;
}
 
.strength-requirements {
  list-style: none;
  padding: 0;
  margin: 0.5rem 0 0;
  font-size: 0.875rem;
}
 
.strength-requirements li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.25rem 0;
}
 
.strength-requirements .met {
  color: #22c55e;
}
 
.strength-requirements .unmet {
  color: #666;
}
 
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

Security Best Practices

PracticeImplementation
Never log passwordsExclude from logging, monitoring
Hash passwordsUse bcrypt, Argon2, or scrypt
Enforce minimum lengthAt least 12 characters
Check against breachesUse Have I Been Pwned API
Rate limit attemptsPrevent brute force attacks
Use HTTPSEncrypt in transit

Breach Check Integration

TypeScript
// Check password against known breaches (k-anonymity safe)
async function checkPasswordBreach(password: string): Promise<boolean> {
  const encoder = new TextEncoder()
  const data = encoder.encode(password)
  const hashBuffer = await crypto.subtle.digest('SHA-1', data)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase()
 
  const prefix = hashHex.slice(0, 5)
  const suffix = hashHex.slice(5)
 
  // Only send first 5 chars (k-anonymity)
  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`)
  const text = await response.text()
 
  // Check if suffix is in response
  return text.includes(suffix)
}

Exceptions

  • A weaker form control is only acceptable when the business requirement and compensating controls are documented explicitly.
  • If the flow is already transport-insecure, inaccessible, or externally embedded in a way that changes the threat model, fix that stronger issue first.
  • False positives are common on demo, sandbox, or intentionally constrained flows, but they should still be bounded and clearly labeled.

Verification

Automated Checks

  • Test show/hide toggle with keyboard (Enter and Space)
  • Test form submission with validation errors
  • Test in 1Password, LastPass, Bitwarden

Manual Checks

  • Verify autocomplete attributes work with password managers
  • Check screen reader announces toggle state
  • Verify strength indicator updates in real-time
  • Check focus management on error state