Step 3 — Login and what a token is
Check a password without unhashing it, and hand back proof for the next request.
- Check a password with
bcrypt.CompareHashAndPassword. - Explain why "wrong email" and "wrong password" must return the same error.
- Issue a short-lived signed JWT and decode it to read its claims.
Every command for this step
touch internal/user/token.gogo build ./... && go run ./cmd/api# log in and keep the tokenTOKEN=$(curl -s -X POST localhost:8080/v1/auth/login \ -d '{"email":"a@b.c","password":"password123"}' | jq -r .access_token)echo $TOKEN# read your own claims — this is the lessonecho $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null; echo# wrong password, and an email that does not exist: identical answerscurl -s -X POST localhost:8080/v1/auth/login -d '{"email":"a@b.c","password":"wrong"}'curl -s -X POST localhost:8080/v1/auth/login -d '{"email":"no@x.c","password":"wrong"}'# and similar timingtime curl -s -o /dev/null -X POST localhost:8080/v1/auth/login -d '{"email":"a@b.c","password":"wrong"}'time curl -s -o /dev/null -X POST localhost:8080/v1/auth/login -d '{"email":"no@x.c","password":"wrong"}'Why a token is needed at all
HTTP has no memory. Each request arrives on its own, and the server has no idea it is the same person who logged in a moment ago. Without something to carry, every request would have to include the password. The client would have to store it, and every request would pay the bcrypt cost.
So logging in hands back a token: a piece of text the client sends with later requests to prove who it is.
What a JWT is
This project uses a JWT (JSON Web Token): three chunks of base64url joined by dots: a header, a payload, and a signature.
The payload holds a few facts, called claims: here, the user id and an expiry time. The signature is made from the first two parts plus a secret only your server knows. Change any part of the token and the signature no longer matches, so your server can trust it without looking anything up.
That last part is the appeal. Checking a session in a database costs a query on every request; checking an HMAC signature costs microseconds of CPU and no network round trip.
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc... . 4pRXK9Bv2hZ...└──── header ────┘ └──────── payload ────────┘ └── signature ──┘ which algorithm the claims: sub, exp proof of the aboveFile 1 — checking the password
func (s *Service) Login(ctx context.Context, email, password string) (string, error) { u, err := s.repo.ByEmail(ctx, strings.ToLower(strings.TrimSpace(email))) if err != nil { // Hash anyway, so a missing account takes as long as a wrong password. bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) return "", ErrInvalidCredentials } // Hashes the attempt with the salt stored inside u.PasswordHash and compares // in constant time. Nothing is ever unhashed. if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { return "", ErrInvalidCredentials // the SAME error as above } return s.issueToken(u.ID)}Why both failures must look the same
If "no such account" and "wrong password" give different messages, anyone can feed your login a list of emails and learn which ones have accounts here. That is a privacy leak on its own, and it hands an attacker a list of accounts to target.
Timing counts too. If a missing account returns instantly while a wrong password takes 100ms of bcrypt, the difference is easy to measure, so you hash against a throwaway value in the missing case as well.
File 2 — issuing the token
package userimport ( "strconv" "time" "github.com/golang-jwt/jwt/v5")func (s *Service) issueToken(userID int64) (string, error) { claims := jwt.RegisteredClaims{ Subject: strconv.FormatInt(userID, 10), // "sub" — who this is about IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)}Why only fifteen minutes
The catch with JWTs: you cannot cancel one. Once signed, it stays valid until it expires. There is no list to remove it from, and if you added one, you would be doing a database lookup on every request, which is the thing the JWT was meant to avoid.
So the token is short-lived. Fifteen minutes means a stolen token is useful for at most fifteen minutes. Step 7 adds a refresh token: long-lived, stored in the database, and therefore cancellable. The short token does the work; the long one can be revoked.
File 3 — the handler
func (h *Handler) login(w http.ResponseWriter, r *http.Request) { var in struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil { httpx.Error(w, 400, "invalid json") return } token, err := h.svc.Login(r.Context(), in.Email, in.Password) if err != nil { httpx.Error(w, 401, "invalid credentials") // never say which part was wrong return } httpx.JSON(w, 200, map[string]any{ "access_token": token, "token_type": "Bearer", "expires_in": 900, // seconds, so the client knows when to refresh })}Try it, then read your own token
TOKEN=$(curl -s -X POST localhost:8080/v1/auth/login \ -d '{"email":"a@b.c","password":"password123"}' | jq -r .access_token)# Decode the middle chunk yourselfecho $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null# {"sub":"1","iat":1757...,"exp":1757...}# Wrong password, and an email that does not exist — identical responsescurl -s -X POST localhost:8080/v1/auth/login -d '{"email":"a@b.c","password":"wrong"}'curl -s -X POST localhost:8080/v1/auth/login -d '{"email":"nope@x.c","password":"wrong"}'- HTTP has no memory, so login hands back a token that later requests carry.
- A JWT payload is readable by anyone. The signature only proves it was not altered.
- Return the identical error and similar timing for a missing account and a wrong password.
- A JWT cannot be cancelled, so it is short-lived. Revoking is the refresh token’s job.
- 1Set the expiry to 10 seconds, wait, and confirm the token stops working.
- 2Change one character in the middle of a token and confirm it is rejected.
- 3Time a login with a real email and with an unknown one. They should be close.
- 4Work out what an attacker gains from a stolen token, and what they do not.