{
 "id": "replay",
 "title": "A stolen login",
 "recorded": "2026-09-26T14:33:43.137Z",
 "env": "JABBO API + Postgres 16, local copy, mock gyms",
 "steps": [
  {
   "at": 339,
   "actor": "Member's phone",
   "say": "Signs in. Gets a 15-minute pass and a one-time refresh token.",
   "req": "POST /api/auth/login",
   "status": 200,
   "ms": 45,
   "res": {
    "refresh": "…WLAH"
   }
  },
  {
   "at": 339,
   "actor": "Thief",
   "say": "Copies the refresh token off the phone.",
   "res": {
    "refresh": "…WLAH"
   }
  },
  {
   "at": 342,
   "actor": "Member's phone",
   "say": "Refreshes as normal. The old token is spent and a new one replaces it.",
   "req": "POST /api/auth/refresh",
   "status": 200,
   "ms": 3,
   "res": {
    "spent": "…WLAH",
    "next": "…N0r3"
   }
  },
  {
   "at": 344,
   "actor": "Thief",
   "say": "Replays the copied token.",
   "req": "POST /api/auth/refresh",
   "status": 401,
   "ms": 2,
   "res": {
    "error": "refresh_rejected",
    "reason": "reused"
   },
   "verdict": "caught"
  },
  {
   "at": 345,
   "actor": "Member's phone",
   "say": "Its next refresh is refused too: the whole family of tokens is dead.",
   "req": "POST /api/auth/refresh",
   "status": 401,
   "ms": 1,
   "res": {
    "error": "refresh_rejected",
    "reason": "revoked"
   },
   "verdict": "signed out"
  },
  {
   "at": 408,
   "actor": "Database",
   "say": "Every token from that sign-in is revoked.",
   "sql": "select count(*), count(revoked_at) from refresh_tokens where family_id = …",
   "res": {
    "tokens": 2,
    "revoked": 2
   },
   "verdict": "revoked"
  }
 ]
}