Dev Skills

Step 7 — Refresh tokens and logout

Make sessions endable, without a database lookup on every request.

Topic 72 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Issue refresh tokens and store only their SHA-256 hash.
  • Rotate refresh tokens so each one can be used only once.
  • Build logout that revokes a session.

Every command for this step

terminal — run these in order
mkdir -p internal/auth
touch internal/auth/refresh.go internal/auth/service.go
go build ./... && go run ./cmd/api
# log in and keep both tokens
RESP=$(curl -s -X POST localhost:8080/v1/auth/login -d '{"email":"a@b.c","password":"password123"}')
ACCESS=$(echo $RESP | jq -r .access_token)
REFRESH=$(echo $RESP | jq -r .refresh_token)
# refresh once — works
curl -s -X POST localhost:8080/v1/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}" | jq
# use the SAME refresh token again — must fail, that is rotation
curl -si -X POST localhost:8080/v1/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}" | head -1
# nothing in the table can be used to log in
docker exec -it exp-pg psql -U postgres -d expenses -c 'SELECT token_hash, revoked_at FROM refresh_tokens;'

The gap left in step 3

Your access token cannot be cancelled. If somebody steals it, or a user clicks "log out", the token keeps working until it expires. Fifteen minutes limits that, but "log out" doing nothing is not acceptable.

The standard answer is two tokens. A short access token: the JWT you already have, never checked against the database. And a long refresh token: random, stored in the database, cancellable, and useful for exactly one thing, getting a new access token.

Access tokenRefresh token
Lifetime15 minutes7 days
Forma signed JWTrandom bytes, no meaning
Stored server-side?noyes — as a hash
Checked against the database?neveron every use
Can be cancelled?noyes
Sent with normal requests?yesno — only to /auth/refresh

File 1 — issuing one, and storing only the hash

internal/auth/refresh.go — create this file
func (s *Service) issueRefresh(ctx context.Context, userID int64) (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil { // crypto/rand
return "", err
}
token := base64.RawURLEncoding.EncodeToString(b[:])
// Store the HASH, never the token itself.
sum := sha256.Sum256([]byte(token))
_, err := s.db.ExecContext(ctx,
`INSERT INTO refresh_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, $3)`,
hex.EncodeToString(sum[:]), userID, time.Now().Add(7*24*time.Hour))
return token, err // the raw token goes to the client exactly once
}
Same idea as a password: if somebody steals the table they get hashes, which cannot be used to log in.
Watch out: Use crypto/rand, never math/rand. math/rand is not built to resist prediction. With its classic generator, enough output lets an attacker recover the internal state and predict every token you issue after that. This applies to session ids, password reset codes, and anything else that must be unguessable.

Why SHA-256 here but bcrypt for passwords

bcrypt is slow so that guessing *human* passwords is expensive, because people choose short, predictable ones.

A refresh token is 32 random bytes: 2^256 possibilities. No amount of guessing gets through that, slow hash or fast, so SHA-256 is enough, and it keeps every refresh cheap.

File 2 — rotation

Rotation means each refresh token can be used once. Using it gives you a new pair and cancels the old one. The check and the cancel have to be one statement; a SELECT followed by an UPDATE would let two requests with the same token both get through.

internal/auth/service.go — create this file
func (s *Service) Refresh(ctx context.Context, token string) (access, refresh string, err error) {
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
// Check and cancel in ONE statement, so two requests racing with the same
// token cannot both succeed. A refresh token works exactly once.
var userID int64
err = s.db.QueryRowContext(ctx,
`UPDATE refresh_tokens SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now()
RETURNING user_id`,
hash,
).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrInvalidCredentials // wrong, expired, or already used
}
if err != nil {
return "", "", err
}
if access, err = s.issueToken(userID); err != nil {
return "", "", err
}
refresh, err = s.issueRefresh(ctx, userID)
return access, refresh, err
}

Why single-use matters

Rotation is what makes theft detectable. If a refresh token is ever used twice, one of two things happened: your client has a bug, or somebody stole it and both parties are now using it.

The safe response is to cancel every refresh token for that user and force a fresh login. The Refresh above only rejects the reused token; to react to reuse, look the hash up again when the UPDATE matches nothing. Without rotation you cannot detect the theft at all: the attacker keeps refreshing alongside the real user for as long as the token lives.

File 3 — logout

internal/auth/service.go — add this
func (s *Service) Logout(ctx context.Context, token string) error {
sum := sha256.Sum256([]byte(token))
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`,
hex.EncodeToString(sum[:]))
return err
}
// "Log out everywhere" is the same query without the token:
// UPDATE refresh_tokens SET revoked_at = now()
// WHERE user_id = $1 AND revoked_at IS NULL
Note: Be clear about what logout does. The refresh token stops working immediately, but the access token keeps working until it expires, up to fifteen minutes later. That is the price of not checking the database on every request, and it is why the lifetime is fifteen minutes and not eight hours.
The short version
  • Two tokens: a short JWT that does the work, and a long stored token that can be cancelled.
  • Store the SHA-256 of the refresh token, never the token.
  • Rotate on every refresh. Single use is what makes theft detectable.
  • crypto/rand for anything that must be unguessable. math/rand there is a vulnerability.
Remember: You cannot cancel a JWT. You cancel the refresh token and wait out the access token, which is why it is short.
Build this before moving on
Add refresh and logout. Then use one refresh token twice and confirm the second attempt fails.
Then try these in your own editor
  1. 1Log out, then keep using the access token. Time how long it keeps working.
  2. 2Look at the refresh_tokens table and confirm nothing in it can be used to log in.
  3. 3Add "log out everywhere" and test it from two different terminals.
  4. 4Write the cleanup job that deletes tokens expired more than 30 days ago, and give its goroutine a way to stop.
Check yourselfTake the Project 2 — Expense tracker test