Dev Skills

Step 3 — Login and what a token is

Check a password without unhashing it, and hand back proof for the next request.

Topic 68 of 98 · 10 min to read · 50 min to build
After this topic you can
  • 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

terminal — run these in order
touch internal/user/token.go
go build ./... && go run ./cmd/api
# log in and keep the token
TOKEN=$(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 lesson
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null; echo
# wrong password, and an email that does not exist: identical answers
curl -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 timing
time 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.

what a token looks like
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc... . 4pRXK9Bv2hZ...
└──── header ────┘ └──────── payload ────────┘ └── signature ──┘
which algorithm the claims: sub, exp proof of the above
Paste the middle chunk into any base64 decoder and you can read it. That is not a bug; see the warning below.
Watch out: A JWT is signed, not encrypted. Anyone holding it can read the payload. The signature only proves nobody changed it. Put an id and an expiry in there, nothing more: never a password, never an email you would not want leaked, never anything secret.

File 1 — checking the password

internal/user/service.go — add this
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)
}
Both failures return the identical error, and the dummy hash makes them take a similar amount of time.

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

internal/user/token.go — create this file
package user
import (
"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

internal/user/handler.go — add this
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

terminal
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 yourself
echo $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 responses
curl -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"}'
Seeing your own user id in plain text is the lesson. That is what "signed, not encrypted" means in practice.
The short version
  • 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.
Remember: You never unhash a password. You hash the attempt and compare, which is why a stolen database leaves an attacker with slow, expensive guessing.
Build this before moving on
Add login and get a token back. Decode the payload yourself and read the claims. Then check that a wrong password and an unknown email give byte-identical responses.
Then try these in your own editor
  1. 1Set the expiry to 10 seconds, wait, and confirm the token stops working.
  2. 2Change one character in the middle of a token and confirm it is rejected.
  3. 3Time a login with a real email and with an unknown one. They should be close.
  4. 4Work out what an attacker gains from a stolen token, and what they do not.
Check yourselfTake the Project 2 — Expense tracker test