Security: Hash backup codes instead of storing in plain text #2

Closed
opened 2025-10-23 07:12:54 +00:00 by Claude · 1 comment

Security Issue: Plain Text Backup Code Storage

Current Implementation

Backup codes are currently stored as plain JSON in the backup_codes column in the database:

# app/models/user.rb
def verify_backup_code(code)
  codes = JSON.parse(backup_codes)  # Plain text codes
  if codes.include?(code)
    codes.delete(code)
    update(backup_codes: codes.to_json)
    true
  end
end

The Problem

  • ✗ Anyone with database access can read all backup codes
  • ✗ Backup codes visible from Rails console
  • ✗ Database compromise exposes 2FA bypass codes
  • ✗ Violates principle of treating auth credentials like passwords

Industry Best Practice

Backup codes should be hashed (like passwords) and only shown once during generation:

Services that hash backup codes:

  • GitHub - shows once, then hashes
  • Google - shows once, then hashes
  • Most enterprise SSO providers

Recommended Approach:

  1. Generate backup codes and show them once to the user
  2. Hash each code with bcrypt before storing
  3. On verification, hash the entered code and compare
  4. Remove the matching hash from the array
# Pseudocode for improved implementation
def verify_backup_code(code)
  return false unless backup_codes.present?
  
  hashed_codes = JSON.parse(backup_codes)
  
  # Find matching hash
  matching_hash = hashed_codes.find do |hashed|
    BCrypt::Password.new(hashed) == code
  end
  
  if matching_hash
    hashed_codes.delete(matching_hash)
    update(backup_codes: hashed_codes.to_json)
    true
  else
    false
  end
end

Trade-offs

Current approach (plain text):

  • ✓ Can re-display backup codes to user (with password verification)
  • ✗ Security risk if database compromised

Hashed approach:

  • ✓ Secure - codes invisible even with database access
  • ✓ Industry standard practice
  • ✗ Cannot re-display codes to user

Note: Most services consider this trade-off acceptable - backup codes are shown once with strong warnings to save them securely.

Proposed Changes

  1. Update User#verify_backup_code to hash and compare codes
  2. Update backup code generation to hash before storing
  3. Remove "View Backup Codes" feature (or only allow during initial setup)
  4. Update backup codes view to emphasize "save these now - you won't see them again"
  5. Consider adding "regenerate backup codes" feature (invalidates old ones, generates new set)

References

Priority

Medium - This is a security improvement but requires database access to exploit. Should be addressed before production deployment or when handling sensitive data.


Related to: #1 (Development Progress Tracker)

## Security Issue: Plain Text Backup Code Storage ### Current Implementation Backup codes are currently stored as plain JSON in the `backup_codes` column in the database: ```ruby # app/models/user.rb def verify_backup_code(code) codes = JSON.parse(backup_codes) # Plain text codes if codes.include?(code) codes.delete(code) update(backup_codes: codes.to_json) true end end ``` ### The Problem - ✗ Anyone with database access can read all backup codes - ✗ Backup codes visible from Rails console - ✗ Database compromise exposes 2FA bypass codes - ✗ Violates principle of treating auth credentials like passwords ### Industry Best Practice Backup codes should be **hashed** (like passwords) and only shown once during generation: **Services that hash backup codes:** - GitHub - shows once, then hashes - Google - shows once, then hashes - Most enterprise SSO providers **Recommended Approach:** 1. Generate backup codes and show them once to the user 2. Hash each code with bcrypt before storing 3. On verification, hash the entered code and compare 4. Remove the matching hash from the array ```ruby # Pseudocode for improved implementation def verify_backup_code(code) return false unless backup_codes.present? hashed_codes = JSON.parse(backup_codes) # Find matching hash matching_hash = hashed_codes.find do |hashed| BCrypt::Password.new(hashed) == code end if matching_hash hashed_codes.delete(matching_hash) update(backup_codes: hashed_codes.to_json) true else false end end ``` ### Trade-offs **Current approach (plain text):** - ✓ Can re-display backup codes to user (with password verification) - ✗ Security risk if database compromised **Hashed approach:** - ✓ Secure - codes invisible even with database access - ✓ Industry standard practice - ✗ Cannot re-display codes to user **Note:** Most services consider this trade-off acceptable - backup codes are shown once with strong warnings to save them securely. ### Proposed Changes 1. Update `User#verify_backup_code` to hash and compare codes 2. Update backup code generation to hash before storing 3. Remove "View Backup Codes" feature (or only allow during initial setup) 4. Update backup codes view to emphasize "save these now - you won't see them again" 5. Consider adding "regenerate backup codes" feature (invalidates old ones, generates new set) ### References - [OWASP: Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - GitHub's 2FA implementation shows codes once only - Google's backup codes are one-time view only ### Priority **Medium** - This is a security improvement but requires database access to exploit. Should be addressed before production deployment or when handling sensitive data. --- Related to: #1 (Development Progress Tracker)
Owner

complete

complete
dkam closed this issue 2025-11-05 08:33:52 +00:00
Sign in to join this conversation.
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dkam/clinch#2