Why JWT Token
JWT Token?
JWT token is assigned to a user when user Authenticates and then
token is used as Bearer token in every request to access the REST
endpoint.
Issue with 1 Token System(ie only JWT Token)?
JWT Token is often associated with Password, ie whenever user entered
correct password a new JWT token is provided to the user.
Long JWT Token expiry(3 months):
if JWT token expiry is kept for longer period and token is
compromised. Malicious user can attack system for longer duration of
time.
Long lived JWT tokens are hard to revoke and permanent key to
system for entire duration(eg: 90 days)
Short JWT Token expiry(24 hours):
if JWT token expiry is smaller (ie 1 day), then after every 1 day
user has to enter his password again.
Solution: Maintain 2 tokens.
Short lived(24 hours): access token = JWT Token
Long lived(90 days): uuid = Refresh token
Refresh Token (uuid)
Advantages?
1. Only short lived token(15 min or 1 hour life) token floats on
wire, it they are compromised then even, attack duration is reduced.
2. User need not to enter password again and again in case JWT
token has expired and needs renewal.
Flow
-
Now Server returns 2 tokens on login to frontend.
1. access_token = JWT Token: short‑lived JWT (for example, 15–60 minutes).
2. refresh_token: long‑lived random string stored server‑side (e.g., 7–90 days)
3. Client/frontend uses Refresh token to renew access/JWT token
4. Backend issues both new refresh, JWT token to frontend.
Can't Refresh token be Compromised?
-
Yes, if JWT token can be compromised, so as refresh token can also be
compromised. But once MIM tries to use the refresh token he will be
detected
DB Schema for storing Refresh Tokens
-
We will not store JWT token in database, but we will store refresh token
in database
`refresh_tokens` table stores::
- `id` (BIGSERIAL primary key)
- `device_id`. (device for which token is generated)
- `token_hash` (SHA-256 hex of the raw refresh token)
- `expires_at` (timestamp when the token stops being valid)
- `created_at` (timestamp when the row was inserted)
- `revoked_at` (timestamp when the token was revoked; `NULL` means active)
There can be muliple refresh tokens per user. Past tokens are kept for auditing purposes.
crm=# \d refresh_tokens;
Table "public.refresh_tokens"
Column | Type | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+--------------------------------------------
id | bigint | | not null | nextval('refresh_tokens_id_seq'::regclass)
device_id | integer | | not null |
token_hash | character varying(64) | | not null |
expires_at | timestamp with time zone | | not null |
created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
revoked_at | timestamp with time zone | | |
Indexes:
"refresh_tokens_pkey" PRIMARY KEY, btree (id)
"idx_refresh_tokens_device" btree (device_id)
"idx_refresh_tokens_hash_active" UNIQUE, btree (token_hash) WHERE revoked_at IS NULL
id | device_id | token_hash | expires_at | created_at | revoked_at
----+-------------+----------------+-------------------------------+-------------------------------+------------
1 | 1 | h1 | 2026-07-02 17:28:56.528082+00 | 2026-04-03 17:28:56.52877+00 |
2 | 1 | h2 | 2026-07-02 17:29:11.323039+00 | 2026-04-03 17:29:11.323316+00 |
3 | 1 | h3 | 2026-08-27 09:23:13.566948+00 | 2026-05-29 09:23:13.568534+00 |
4 | 1 | h4 | 2026-08-28 14:39:09.676561+00 | 2026-05-30 14:39:09.677993+00 |
5 | 2 | h5 | 2026-08-28 14:40:40.480155+00 | 2026-05-30 14:40:40.480301+00 |
6 | 2 | h6 | 2026-08-28 14:40:49.488028+00 | 2026-05-30 14:40:49.488215+00 |
HLD (High Level Design)
-
- There can be muliple refresh tokens per user. Past tokens are kept for auditing purposes.
- When user logins, we will create a new refresh token and insert into DB. We will also create JWT token and send both tokens to frontend.
- When user access REST endpoint, we will validate the JWT token. If JWT token is valid, we will allow access to REST endpoint. If JWT token is expired, we will return 401 Unauthorized.
- When user calls refresh endpoint with refresh token, we will check if the refresh token is valid by comparing the hash of the received refresh token with the hash stored in DB. If they match and token is not expired and not revoked, then we will revoke the old refresh token and issue new JWT and refresh token to frontend.
- If they don't match, then we will return 401 Unauthorized and this can be an MIM Attack. Tenant Admin will revoke after 2factor authentication.
!theme plain
!define AWSPUML https://raw.githubusercontent.com/awslabs/aws-icons-for-plantuml/v14.0/dist
!option handwritten true
actor Attacker #Red
actor Admin #Cyan
actor Client #Pink
participant Backend
database RefreshTokenTable
Client -> Backend: POST /login {credentials}
note over Backend
user, passwd valid
Create JWT Token
Create Refresh Token
end note
Backend -> RefreshTokenTable: INSERT refresh_tokens\n(employee_id=101, token_hash=H1, expires_at=...)
Backend --> Client: jwtToken1(expiry=24hour)\nrefreshToken1(expiry=3months)
Client -> Backend: GET /api/resource
alt jwtToken1 valid
Backend --> Client: 200 OK
else jwtToken1 expired after 24 hours
Backend --> Client: 401 Unauthorized
end
note over Backend
jwtToken1 expired
refreshToken1 is valid
Client want new JWTtoken
using refreshToken
end note
Client -> Backend: POST /token/refresh {refreshToken1}
note over RefreshTokenTable
Find refreshToken
HashSentFromFrontend == HashStoredinDB
Assigned to employee,
Not expired
end note
Backend -> RefreshTokenTable: SELECT employee_id FROM refresh_tokens \nWHERE token_hash=H1 AND revoked_at IS NULL AND expires_at > now
alt valid
note over RefreshTokenTable
Revoke the Token recieved from Frontend
end note
Backend -> RefreshTokenTable: UPDATE refresh_tokens \nSET revoked_at=NOW() WHERE token_hash=H1 AND revoked_at IS NULL
note over Backend
Create a new JWT, refreshToken for employee
Insert into DB
end note
Backend -> RefreshTokenTable: INSERT refresh_tokens\n(employee_id=101, token_hash=H2, expires_at=...)
Backend --> Client: {jwtToken2, refreshToken2}
else invalid
note over Attacker
Attacker had
stored
refresh-token1
and replays
end note
Attacker -> Backend: POST /token/refresh {refreshToken1}
Backend -> RefreshTokenTable: Query token
Backend <-- RefreshTokenTable: refreshToken1 expired
Backend --> Attacker: 401 invalid_refresh_token
Backend --> Admin: ATTACK IN PLACE (Block Client)
note over Admin
Admin logs
using 2FA
block Client
access
end note
end
@enduml
Detecting MIM(Man in Middle)
-
Covered in above flow diagram.
How the current design handles MIM attack risk
-
- The refresh design rotates both tokens on every successful refresh.
- If an attacker attempts to reuse `refreshToken1` after the backend has rotated it, the request fails because the old row now has `revoked_at != NULL`.
- The DB checks both `revoked_at IS NULL` and `expires_at > now`, so revoked or expired tokens cannot be reused.
Compromise and logout recovery
-
If an employee is compromised, the main objective is to stop refresh token reuse and force re-authentication.
Revoke compromised refresh tokens
-
1. Mark all active refresh tokens for the employee as revoked.
This step can be carried by tenant admin after 2 step authentication.
UPDATE refresh_tokens SET revoked_at = NOW() WHERE employee_id = X AND revoked_at IS NULL
2. Keep revoked rows for audit or cleanup later.
3. After this, no refresh token for that employee can produce a new JWT.
Logging out all devices
-
To force a complete logout:
1. revoke all refresh tokens for the employee
2. optionally update a user-wide session/version or invalidate access tokens centrally
3. let the existing JWT token expire
4. require full login again to issue a fresh JWT and refresh token