Session 04 ยท โ™ป๏ธ Lifecycle

Token Lifecycle

Short-lived access, long-lived refresh โ€” and a way to take both back.

โœ“
4aaccess vs refresh
โœ“
4bcookie vs Bearer
โœ“
4crotation & revocation

4a

Two tokens, two jobs

The access token is used constantly and dies fast. The refresh token is used rarely and lives for days.

ONE 7-DAY SESSION
access ยท 15m
refresh ยท 7d
loginday 3day 7 โ†’ log in again
Access token
Refresh token
format
signed JWT
random 32 bytes (opaque)
lifetime
5โ€“15 minutes
7โ€“30 days
sent to
every API call
only /auth/refresh
checked by
signature (no DB)
lookup in Redis
revocable
not by itself
delete the key
โ†’A stolen access token works for minutes, not days.
โ†’The refresh token is stored server-side, so it can be revoked instantly.

4b

Where the browser keeps them

Access token in memory, sent as a Bearer header. Refresh token in a cookie JavaScript cannot touch.

internal/auth/cookie.go
http.SetCookie(w, &http.Cookie{
    Name:     "refresh_token",
    Value:    refresh,
    Path:     "/auth/refresh", // sent nowhere else
    MaxAge:   int(refreshTTL.Seconds()),
    HttpOnly: true,  // invisible to JavaScript
    Secure:   true,  // HTTPS only
    SameSite: http.SameSiteStrictMode, // no cross-site
})
go run ./demos/04 ยท the response header
Set-Cookie: refresh_token=64d16837914bb63a; Path=/auth/refresh; Max-Age=604800; HttpOnly; Secure; SameSite=Strict
Threat
localStorage + Bearer
HttpOnly cookie
XSS reads it
yes โ€” any script can
no
CSRF sends it
no โ€” must be added by code
blocked by SameSite
works for mobile
yes
awkward
โœ“Web app: refresh in HttpOnly cookie, access in a JS variable (lost on reload โ†’ just refresh).
โ†’Mobile / API clients: both in the Authorization header, stored in the OS keychain.
โœ—Never keep a refresh token in localStorage.

4c

Rotation, reuse detection and the denylist

Every refresh swaps the old token for a new one. If an old one ever comes back, someone copied it.

internal/auth/refresh_store.go
func (s Store) Rotate(ctx context.Context,
    old string) (string, error) {
    v, err := s.rdb.GetDel(ctx, "rt:"+old).Result()
    if err == redis.Nil { // not live: reused or bogus?
        fam, _ := s.rdb.Get(ctx, "used:"+old).Result()
        if fam != "" {
            s.revokeFamily(ctx, fam)
            return "", ErrReuse
        }
        return "", ErrInvalid
    }
    if err != nil {
        return "", err // Redis down: fail closed
    }
    user, fam, _ := strings.Cut(v, "|")
    s.rdb.Set(ctx, "used:"+old, fam, refreshTTL)
    return s.Issue(ctx, user, fam), nil
}

// logout: deny this access token until it expires
s.rdb.Set(ctx, "deny:"+claims.ID, 1, time.Until(exp))
go run ./demos/04 ยท real Redis protocol (miniredis)
login     โ†’ refresh 64d16837โ€ฆ
refresh   โ†’ refresh 9848b325โ€ฆ <nil>
replay r1 โ†’ refresh token reused: family revoked
use r2    โ†’ refresh token invalid
jti-7f3a denied: true  ttl: 9m0s
10m later denied: false
# the denylist key expires by itself once the token would have
โ†’GETDEL is atomic โ€” two parallel refreshes cannot both win.
โ†’Reuse = theft. Revoke the whole family: attacker and user are both logged out.
โ†’Denylist TTL = time left on the token, so Redis never grows forever.
โ†’If Redis errors, fail closed โ€” refuse, never guess.
HANDS-ON ยท TOPIC 1

Stateless auth pipeline with dual tokens

  • POST /auth/login โ€” bcrypt check โ†’ 15-min access JWT in the body + 7-day refresh in an HttpOnly cookie.
  • POST /auth/refresh โ€” rotate the cookie, return a new access token; reuse โ†’ 401 and revoke the family.
  • POST /auth/logout โ€” delete the refresh key, deny the access token's jti.
  • RequireAuth also rejects denied jtis.

Recap

Session 04 in four lines

1Access = short JWT, checked by signature. Refresh = long opaque token, checked in Redis.
2Refresh lives in an HttpOnly; Secure; SameSite=Strict cookie scoped to one path.
3Rotate on every refresh; a reused token revokes the whole family.
4Logout denies the access jti for exactly its remaining lifetime.