Step 7 — Refresh tokens and logout
Make sessions endable, without a database lookup on every request.
- 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
mkdir -p internal/authtouch internal/auth/refresh.go internal/auth/service.gogo build ./... && go run ./cmd/api# log in and keep both tokensRESP=$(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 — workscurl -s -X POST localhost:8080/v1/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}" | jq# use the SAME refresh token again — must fail, that is rotationcurl -si -X POST localhost:8080/v1/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}" | head -1# nothing in the table can be used to log indocker 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 token | Refresh token | |
|---|---|---|
| Lifetime | 15 minutes | 7 days |
| Form | a signed JWT | random bytes, no meaning |
| Stored server-side? | no | yes — as a hash |
| Checked against the database? | never | on every use |
| Can be cancelled? | no | yes |
| Sent with normal requests? | yes | no — only to /auth/refresh |
File 1 — issuing one, and storing only the hash
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}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.
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
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- 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/randfor anything that must be unguessable.math/randthere is a vulnerability.
- 1Log out, then keep using the access token. Time how long it keeps working.
- 2Look at the
refresh_tokenstable and confirm nothing in it can be used to log in. - 3Add "log out everywhere" and test it from two different terminals.
- 4Write the cleanup job that deletes tokens expired more than 30 days ago, and give its goroutine a way to stop.