logo

Database

Non-encrypted confidential information

Need

Storage of password hashes instead of plaintext passwords

Context

• Usage of Elixir 1.15+ for building application services

• Usage of Ecto for database access

• Usage of bcrypt_elixir for password hashing

Description

1. Non compliant code

defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :email, :string
    field :password, :string
  end
end...

The `register_user/2` function below stores the password exactly as the user typed it, with `put_change(:password, password)` followed by `Repo.insert/1`, in a `password` column of the `users` table. Anyone who can read that table sees every password in clear text: a database administrator, a backup operator, an attacker who exploits a SQL injection, or anyone who obtains a leaked backup. Because people reuse passwords, the damage goes beyond this application, since the same credentials can be tried against email, banking and corporate accounts. Encoding the value with `Base.encode64/1` or `:base64.encode/1` before storing it gives no protection. Base64 is a reversible encoding, not a hash, and decoding it takes a single function call.

2. Steps

• Never store passwords with `put_change(:password, ...)` or in any other reversible form, including Base64.

• Declare the password field as `virtual: true, redact: true` and store only a hash in a separate `hashed_password` column.

• Hash passwords with `Bcrypt.hash_pwd_salt/1`, `Argon2.hash_pwd_salt/1` or `Pbkdf2.hash_pwd_salt/1` inside the changeset.

• Verify logins with `verify_pass/2`, and call `no_user_verify/0` when the account does not exist.

• Hash existing plaintext passwords with a migration and ask the affected users to change them.

3. Secure code example

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true...

The corrected schema follows the pattern generated by `mix phx.gen.auth`. The `password` field is `virtual`, so Ecto never writes it to the database, and it is marked with `redact: true`, so it does not appear when the struct or the changeset is inspected or logged. `registration_changeset/2` validates the password length and, only when the changeset is valid, stores `Bcrypt.hash_pwd_salt/1` in `hashed_password` and removes the plaintext change. Bcrypt is a slow, salted password hashing function: each hash has its own random salt, and each guess is expensive for an attacker who obtains the table. At login, `Bcrypt.verify_pass/2` compares the submitted password with the stored hash, and `Bcrypt.no_user_verify/0` should be called when the user does not exist, so both paths take the same time.