Dev Skills

Step 2 — Register: storing a password safely

What hashing is, why you can never read a password back, and how bcrypt works.

Topic 67 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Hash a password with bcrypt and explain why SHA-256 is wrong for passwords.
  • Build a register endpoint that validates input and never stores the plain password.
  • Map a unique-constraint violation to a 409 Conflict.

Every command for this step

terminal — run these in order
touch internal/user/user.go internal/user/service.go
touch internal/user/repository.go internal/user/handler.go
go build ./... && go run ./cmd/api
# register
curl -s -X POST localhost:8080/v1/auth/register \
-d '{"name":"Sahdev","email":"a@b.c","password":"password123"}' | jq
# the same email again — must be a clean 409
curl -si -X POST localhost:8080/v1/auth/register \
-d '{"name":"Sahdev","email":"a@b.c","password":"password123"}' | head -1
# validation failures
curl -s -X POST localhost:8080/v1/auth/register -d '{"email":"a@b.c","password":"password123"}'
curl -s -X POST localhost:8080/v1/auth/register -d '{"name":"x","email":"a@b.c","password":"short"}'
# confirm the hash is nowhere in the response, and look at it in the database
docker exec -it exp-pg psql -U postgres -d expenses -c 'SELECT email, password_hash FROM users;'

The problem registration has to solve

You need to check a password later, but you must never be able to read it. Those sound contradictory. Databases get stolen, backups get left on laptops, and people reuse the same password on their bank.

The answer is hashing. A hash function turns any input into a fixed-size jumble, and it only works one way: you cannot go from the jumble back to the password. To check a login you hash the attempt and compare the two hashes. If your database is stolen, the passwords are not in it.

Why bcrypt and not SHA-256

A normal hash like SHA-256 is designed to be fast, which is wrong for passwords. An attacker with your table and a GPU can try billions of guesses a second.

bcrypt is designed to be slow on purpose, and you can tune how slow. At the default cost of 10, one hash takes roughly 50–100 milliseconds. A person logging in does not notice, and an attacker’s billions of guesses per second drop to thousands.

It also handles the salt for you. A salt is random data mixed into each password before hashing, so two people with the same password get different hashes, and a precomputed table of common passwords is useless. bcrypt generates one and stores it inside the hash string.

File 1 — the type and the request

internal/user/user.go — create this file
package user
import (
"errors"
"time"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"` // "-" means: never put this in JSON
CreatedAt time.Time `json:"created_at"`
}
// RegisterRequest is what a client may send. It is not the User type, so
// nobody can post an "id" or a "password_hash" of their own.
type RegisterRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
var (
ErrNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already registered")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrValidation = errors.New("invalid input")
)
The `json:"-"` on PasswordHash means encoding a User can never include the hash, so nobody has to remember to strip it from each response.

File 2 — validation

internal/user/user.go — add this below
func (r RegisterRequest) Validate() error {
if strings.TrimSpace(r.Name) == "" {
return fmt.Errorf("%w: name is required", ErrValidation)
}
if !strings.Contains(r.Email, "@") || len(r.Email) > 254 {
return fmt.Errorf("%w: a valid email is required", ErrValidation)
}
if len(r.Password) < 8 {
return fmt.Errorf("%w: password must be at least 8 characters", ErrValidation)
}
if len(r.Password) > 72 {
return fmt.Errorf("%w: password must be at most 72 characters", ErrValidation)
}
return nil
}
The 72 is not arbitrary: bcrypt only uses the first 72 bytes. Older versions of x/crypto/bcrypt silently ignored the rest; current ones return ErrPasswordTooLong, which without this check becomes a 500. len counts bytes, so 72 non-ASCII characters are more than 72.

File 3 — the service does the hashing

internal/user/service.go — create this file
package user
import (
"context"
"errors"
"strings"
"golang.org/x/crypto/bcrypt"
)
type Service struct{ repo *Repository }
func NewService(r *Repository) *Service { return &Service{repo: r} }
func (s *Service) Register(ctx context.Context, req RegisterRequest) (*User, error) {
if err := req.Validate(); err != nil {
return nil, err
}
// One line, and it does the salting and the slowness for you.
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &User{
Name: strings.TrimSpace(req.Name),
Email: strings.ToLower(strings.TrimSpace(req.Email)),
PasswordHash: string(hash),
}
if err := s.repo.Create(ctx, u); err != nil {
if errors.Is(err, ErrEmailTaken) {
return nil, ErrEmailTaken
}
return nil, err
}
// req.Password is never stored, logged or returned. It ends here.
return u, nil
}

File 4 — the repository, and letting the database decide

A tempting mistake: SELECT to check the email is free, then INSERT. Without a constraint, two requests can both pass that check and both insert. The unique constraint is what guarantees one account per email, so insert directly and translate the failure.

internal/user/repository.go — create this file
func (r *Repository) Create(ctx context.Context, u *User) error {
err := r.db.QueryRowContext(ctx,
`INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3) RETURNING id, created_at`,
u.Name, u.Email, u.PasswordHash,
).Scan(&u.ID, &u.CreatedAt)
if isUniqueViolation(err) {
return ErrEmailTaken
}
return err
}
// Postgres reports a broken unique constraint as error code 23505.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

File 5 — the handler

internal/user/handler.go — create this file
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
var in RegisterRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields() // a client typo becomes an error, not silence
if err := dec.Decode(&in); err != nil {
httpx.Error(w, 400, "invalid json")
return
}
u, err := h.svc.Register(r.Context(), in)
if err != nil {
switch {
case errors.Is(err, ErrEmailTaken):
httpx.Error(w, 409, "email already registered")
case errors.Is(err, ErrValidation):
httpx.Error(w, 422, err.Error())
default:
slog.Error("register failed", "err", err) // detail to the log
httpx.Error(w, 500, "could not register") // nothing useful to the client
}
return
}
httpx.JSON(w, 201, u) // PasswordHash cannot appear here
}
Watch out: Never log the request body on this endpoint, not even at debug level. A log line containing a plaintext password is a breach, and log stores are often readable by far more people than the database is.
The short version
  • Hashing is one-way. You store the hash and compare hashes; you never read the password back.
  • bcrypt is slow on purpose and salts for you. Never use SHA-256 or anything reversible for passwords.
  • json:"-" on the hash keeps it out of every JSON response without anyone having to remember.
  • Let the unique constraint decide, then translate error 23505. Check-then-insert is a race.
Remember: After Register returns, the plaintext password should exist nowhere: not in a variable you kept, not in a log, not in the response.
Build this before moving on
Build all five files and register a user with curl. Then register the same email again and confirm you get a clean 409, not a raw Postgres error.
Then try these in your own editor
  1. 1Print the bcrypt hash of the same password twice and see that they differ. That is the salt.
  2. 2Time bcrypt.GenerateFromPassword at cost 10, 12 and 14.
  3. 3Send a 100-character password and confirm your validation rejects it before it reaches bcrypt.
  4. 4Check the register response body and confirm no hash appears anywhere in it.
Check yourselfTake the Project 2 — Expense tracker test