Skip to content

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.

https://your-panel-host/api
TokenLifetimeTransportUsed for
Access (JWT)15 minAuthorization: Bearer headerEvery protected API call
Refresh30 daysHttpOnly cookie refresh_token; path /api/authOne 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.

  1. LoginPOST /api/auth/login returns the JWT in the body and sets refresh_token=<opaque> as HttpOnly cookie. The DB row for the refresh token is created with revoked=false.

  2. Use — Front-end attaches Authorization: Bearer <jwt> to every request. After 15 minutes the JWT is expired and the API returns 401 token expired.

  3. Refresh — Front-end calls POST /api/auth/refresh. The cookie is sent automatically by the browser. The handler:

    1. Looks up the cookie value in refresh_tokens.
    2. Confirms revoked=false and expires_at > now.
    3. Marks the row revoked=true and sets replaced_by to the new token id (rotation).
    4. Issues a new JWT and a new refresh cookie.
  4. Replay detection — If a refresh token marked revoked=true is presented again, the handler treats this as a stolen-token incident: it calls RevokeAllForUser(user_id), which marks every active refresh token for that user as revoked. The user must log in again on every device.

  5. LogoutPOST /api/auth/logout blacklists the current JWT in Redis (TTL = remaining lifetime) and revokes the refresh-token row. The cookie is cleared with Max-Age=0.

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/refresh
Cookie: 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

Terminal window
# 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.txt

Errors

StatusmessageCause
401missing refresh tokenNo cookie sent
401invalid refresh tokenToken not in DB
401refresh token revokedToken previously revoked — see replay detection below
401refresh token expiredPast expires_at (30 days)
429rate limit exceededMore than 20 refreshes/min/IP

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:

StepCallerCookie presentedResult
1Legitimate userstolen_token_A200 OK, rotated to new_token_B; row A marked revoked, replaced_by=B
2Attackerstolen_token_A401 — row A is now revoked. Replay detected. Handler calls RevokeAllForUser(user_id)
3Legitimate usernew_token_B401 — 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 = 4

A few replays per month across a large tenant is normal (mobile networks switching IPs, suspended laptops). A burst from one user is worth investigating.

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.

Permission required: any authenticated user.

Request

POST /api/auth/logout
Authorization: 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/auth

After logout:

  • The JWT used to call logout returns 401 invalid token on any subsequent request.
  • The refresh cookie is empty, so /api/auth/refresh returns 401 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).

The refresh-token cookie is set with conservative defaults:

AttributeValueWhy
HttpOnlyyesJS cannot read the cookie — XSS cannot steal it
Secureyes (when served over HTTPS)Cookie not sent over plain HTTP
SameSiteLaxSent on top-level GETs but not on cross-site POSTs
Path/api/authOnly ever sent to the auth surface
Max-Age2592000 (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.

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.

All endpoints in this API surface return errors as:

{
"success": false,
"message": "human-readable reason"
}
LimitEndpoint
20 req/min/IPPOST /api/auth/refresh
300 req/min/IPAll 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.

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).

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.

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 from Set-Cookie after each refresh.