JWT + Refresh Tokens
ProxPanel uses a two-token model: a short-lived access token (JWT) for every API call and a long-lived refresh token (HttpOnly cookie) used only to mint new access tokens. This page documents the rotation flow, the replay-detection logic, and how logout invalidates both.
Base URL
Section titled “Base URL”https://your-panel-host/apiToken roles
Section titled “Token roles”| Token | Lifetime | Transport | Used for |
|---|---|---|---|
| Access (JWT) | 15 min | Authorization: Bearer header | Every protected API call |
| Refresh | 30 days | HttpOnly cookie refresh_token; path /api/auth | One endpoint only: POST /api/auth/refresh |
The access token is stateless — its expiry is the only signal that it has been invalidated, with one exception: the Redis blacklist (see below).
The refresh token is stateful — every refresh token has a row in the refresh_tokens table with revoked, replaced_by, and expires_at columns. The opaque cookie value is a SHA-256 hash key into that row.
The full lifecycle
Section titled “The full lifecycle”-
Login —
POST /api/auth/loginreturns the JWT in the body and setsrefresh_token=<opaque>asHttpOnlycookie. The DB row for the refresh token is created withrevoked=false. -
Use — Front-end attaches
Authorization: Bearer <jwt>to every request. After 15 minutes the JWT is expired and the API returns401 token expired. -
Refresh — Front-end calls
POST /api/auth/refresh. The cookie is sent automatically by the browser. The handler:- Looks up the cookie value in
refresh_tokens. - Confirms
revoked=falseandexpires_at > now. - Marks the row
revoked=trueand setsreplaced_byto the new token id (rotation). - Issues a new JWT and a new refresh cookie.
- Looks up the cookie value in
-
Replay detection — If a refresh token marked
revoked=trueis presented again, the handler treats this as a stolen-token incident: it callsRevokeAllForUser(user_id), which marks every active refresh token for that user as revoked. The user must log in again on every device. -
Logout —
POST /api/auth/logoutblacklists the current JWT in Redis (TTL = remaining lifetime) and revokes the refresh-token row. The cookie is cleared withMax-Age=0.
POST /api/auth/refresh
Section titled “POST /api/auth/refresh”Exchange the refresh-token cookie for a fresh access token. This is the only endpoint that reads the cookie.
Permission required: none (the cookie is the credential).
Request
POST /api/auth/refreshCookie: refresh_token=<opaque>No request body.
Response — 200 OK
{ "success": true, "token": "eyJhbGciOiJIUzI1NiIs...", "user": { "id": 42, "username": "reseller1", "user_type": "reseller", "permissions": ["subscribers.view", "subscribers.create"] }}The response also sets a new refresh_token cookie with the rotated value. The old cookie value is now revoked.
Example
# In a browser, the cookie is sent automatically. From curl:curl -X POST https://panel.example.com/api/auth/refresh \ -b "refresh_token=eyJxxx..." \ -c cookies.txtErrors
| Status | message | Cause |
|---|---|---|
| 401 | missing refresh token | No cookie sent |
| 401 | invalid refresh token | Token not in DB |
| 401 | refresh token revoked | Token previously revoked — see replay detection below |
| 401 | refresh token expired | Past expires_at (30 days) |
| 429 | rate limit exceeded | More than 20 refreshes/min/IP |
Replay detection
Section titled “Replay detection”The whole point of rotating refresh tokens is to detect theft.
Scenario: an attacker steals the cookie value (stolen_token_A). At some later point both the attacker and the legitimate user call /api/auth/refresh:
| Step | Caller | Cookie presented | Result |
|---|---|---|---|
| 1 | Legitimate user | stolen_token_A | 200 OK, rotated to new_token_B; row A marked revoked, replaced_by=B |
| 2 | Attacker | stolen_token_A | 401 — row A is now revoked. Replay detected. Handler calls RevokeAllForUser(user_id) |
| 3 | Legitimate user | new_token_B | 401 — token B was also revoked by RevokeAllForUser. Forced re-login. |
The legitimate user has to log in again, which is annoying but is the correct trade-off: at the moment of detection we cannot tell which of the two callers was the legitimate one, so both are evicted.
The mass-revoke is logged with severity WARN. Operators can see in audit_logs:
audit.action = "refresh_token_replay"audit.entity_id = <user_id>audit.metadata.revoked_count = 4A few replays per month across a large tenant is normal (mobile networks switching IPs, suspended laptops). A burst from one user is worth investigating.
JWT blacklist on logout
Section titled “JWT blacklist on logout”JWTs are stateless, but POST /api/auth/logout puts the calling token into a Redis blacklist with key:
proisp:token:blacklist:<sha256(token)>The TTL of the blacklist entry equals the remaining lifetime of the JWT (at most 15 minutes). After that, the JWT would have expired anyway so the entry is allowed to fall out.
Every protected endpoint’s auth middleware does one EXISTS Redis check against this key. The cost is one round-trip on the same docker network — sub-millisecond — so the impact on throughput is negligible.
POST /api/auth/logout
Section titled “POST /api/auth/logout”Permission required: any authenticated user.
Request
POST /api/auth/logoutAuthorization: Bearer <jwt>Response — 200 OK
{ "success": true, "message": "logged out" }The same response clears the refresh cookie:
Set-Cookie: refresh_token=; Max-Age=0; Path=/api/authAfter logout:
- The JWT used to call logout returns
401 invalid tokenon any subsequent request. - The refresh cookie is empty, so
/api/auth/refreshreturns401 missing refresh token. - Other devices the user logged into on are not affected — their JWTs and refresh tokens are still valid. To force-logout everywhere, an admin uses Admin Sessions (
POST /api/admin/sessions/:hash/kill).
Cookie attributes
Section titled “Cookie attributes”The refresh-token cookie is set with conservative defaults:
| Attribute | Value | Why |
|---|---|---|
HttpOnly | yes | JS cannot read the cookie — XSS cannot steal it |
Secure | yes (when served over HTTPS) | Cookie not sent over plain HTTP |
SameSite | Lax | Sent on top-level GETs but not on cross-site POSTs |
Path | /api/auth | Only ever sent to the auth surface |
Max-Age | 2592000 (30 d) | Matches expires_at in DB |
If you front the API with a CDN or a different cookie-domain strategy, override JWT_COOKIE_DOMAIN in the API container env.
Token-blacklist storage
Section titled “Token-blacklist storage”Blacklist entries live in Redis (proisp-redis container). They are persisted with AOF but not replicated cross-cluster — in an HA setup, a hard failover loses pending blacklist entries. The mitigation: those tokens would have expired within 15 minutes anyway, and RevokeAllForUser (triggered on suspicious behaviour) is also written to PostgreSQL via the refresh_tokens table, which is replicated.
Error format
Section titled “Error format”All endpoints in this API surface return errors as:
{ "success": false, "message": "human-readable reason"}Rate limits
Section titled “Rate limits”| Limit | Endpoint |
|---|---|
| 20 req/min/IP | POST /api/auth/refresh |
| 300 req/min/IP | All other JWT-protected routes (global) |
A blacklisted JWT still counts against the per-IP limit — calling with a logged-out token does not let you bypass rate limiting.
Why not just a long-lived JWT?
Section titled “Why not just a long-lived JWT?”JWTs cannot be revoked without a blacklist. A 30-day JWT means a stolen token gives the attacker 30 days of access. The two-token model means:
- A stolen access token gives at most 15 minutes of access.
- A stolen refresh token is detected the next time the legitimate user calls
/refresh, and mass-revoke kicks in.
The 15-minute window is the worst-case blast radius for the most common attack (XSS read of localStorage).
Storing the access token on the client
Section titled “Storing the access token on the client”The frontend keeps the JWT in memory (Zustand store) and also mirrors it to localStorage so a page reload doesn’t force a re-login. That choice trades off some XSS risk against not nagging the user — the alternative (memory-only) means every reload hits /refresh, which is slower and creates a tight loop where a brief network blip looks like a forced logout.
For new integrations that have a choice, prefer memory-only storage and rely on the refresh cookie for persistence. The cookie is HttpOnly and cannot be read by JS, so it is the safer reservoir of long-lived auth.
Mobile apps and curl
Section titled “Mobile apps and curl”The two-token model is browser-shaped. For mobile apps and curl scripts that cannot or do not want to use cookies:
- Long-running CLI tools should use External API Keys instead — see External API Keys.
- Mobile apps may treat the refresh cookie as an opaque string in a secure store (Keychain / Keystore) and pass it as
Cookie: refresh_token=...on the one refresh call. The rotation semantics work the same — store the new value fromSet-Cookieafter each refresh.
Related pages
Section titled “Related pages”- Authentication — login, IP whitelist, brute-force lockout
- External API Keys — for non-interactive integrations that should not use JWT at all
- Admin Sessions — admin UI for revoking other users’ tokens