Step 2 — Register: storing a password safely
What hashing is, why you can never read a password back, and how bcrypt works.
- 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
touch internal/user/user.go internal/user/service.gotouch internal/user/repository.go internal/user/handler.gogo build ./... && go run ./cmd/api# registercurl -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 409curl -si -X POST localhost:8080/v1/auth/register \ -d '{"name":"Sahdev","email":"a@b.c","password":"password123"}' | head -1# validation failurescurl -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 databasedocker 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
package userimport ( "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"))File 2 — validation
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}File 3 — the service does the hashing
package userimport ( "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.
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
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}- 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.
Register returns, the plaintext password should exist nowhere: not in a variable you kept, not in a log, not in the response.- 1Print the bcrypt hash of the same password twice and see that they differ. That is the salt.
- 2Time
bcrypt.GenerateFromPasswordat cost 10, 12 and 14. - 3Send a 100-character password and confirm your validation rejects it before it reaches bcrypt.
- 4Check the register response body and confirm no hash appears anywhere in it.