🎫 JWT - JSON Web Token¶
What is it?¶
A JWT is a self-contained and signed token that the server issues when logging in and the client sends with each subsequent request.
- Self-contained: carries all user information inside (
user_id,role, etc.). The server stores nothing between requests. - Signed: HIX uses HMAC-SHA256 with a secret key. If the client alters even one byte, the signature no longer matches and the token is rejected.
- Stateless: two servers with the same key validate the same token → scales horizontally without shared storage.
header.payload.signature
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 {"typ":"JWT","alg":"HS256"}
.eyJ1c2VyX2lkIjoiNDIiLCJyb2xlIjoi... {"user_id":"42","role":"admin","exp":...}
.aBcD3f9eGgHhIi... HMAC-SHA256(header.payload, secret)
When to use it¶
| Use case | JWT |
|---|---|
| Stateless REST API | ✅ Yes—canonical pattern |
| Mobile app calling an API | ✅ Yes |
| Microservices with shared tokens | ✅ Yes |
| SPA calling a separate backend | ✅ Yes |
| Traditional web app with login form | ❌ Use Sessions |
| Single-use tokens (download, password reset) | ⚠️ Yes, with short exp |
JWT vs Session: JWT doesn't require server storage but cannot be invalidated before expiration. Session is the opposite: it requires storage but you can destroy the SID and log the client out instantly.
Setup¶
HIX_MwJwtSetup( ;
"super_secret_and_long_key", ; // cKey - HMAC secret
3600 ) // nExpSecs - token TTL (1h)
Call it before oSrv:Start(). If you don't configure it, HIX uses
hix-secret-key by default (⚠️ insecure).
Issue a token in /login¶
// POST /api/login
FUNCTION Main()
LOCAL oVal, hUser, cToken
oVal := UValidateOrFail( { ;
"username" => "required|string", ;
"password" => "required|string" ;
} )
IF oVal == NIL ; RETURN ; ENDIF
hUser := ModelUser( oVal:Get( "username" ), oVal:Get( "password" ) )
IF ValType( hUser ) != "H"
USendJson( { "error" => "invalid_credentials" }, 401 )
RETURN
ENDIF
cToken := HIX_JwtEncode( { ;
"user_id" => hUser[ "id" ], ;
"name" => hUser[ "name" ], ;
"scope" => "read:products write:orders" ;
} )
USendJson( { "token" => cToken, "expires_in" => 3600 } )
RETURN
HIX_JwtEncode automatically adds standard claims:
| Claim | Value |
|---|---|
iss |
"HIX" |
iat |
Unix timestamp of issue |
exp |
iat + nExpSecs |
What you add (user_id, scope, role, ...) travels alongside.
Protect a route¶
// Pipeline: JWT validation -> handler
oSrv:AddRouteGet( "me", "/api/me", ;
{|| USendJson( UContext():hData["jwt"] ) }, ;
"HIX_MwJwt" )
{ "name": "me", "url": "/api/me", "method": "GET",
"action": "controllers/me.prg",
"middleware": "HIX_MwJwt" }
The client must send:
HIX_MwJwt validates the signature + exp, leaves the payload in
oCtx:hData["jwt"], and continues. If the token is missing or invalid →
401.
Read the payload in the controller¶
PROCEDURE Main(...)
LOCAL oCtx := UContext()
LOCAL hJwt := oCtx:hData[ "jwt" ]
LOCAL cUser := hJwt[ "user_id" ]
LOCAL cRole := hb_HGetDef( hJwt, "role", "" )
USendJson( { "user_id" => cUser, "role" => cRole } )
RETURN
Scopes—authorization by operation¶
HIX_MwJwtScope requires that the scope claim of the token contains all the tokens (separated by space) that the route declares as necessary:
{ "name": "products.list", "url": "/api/products", "method": "GET",
"middleware": "HIX_MwJwt,HIX_MwJwtScope", "scope": "read:products" }
{ "name": "products.delete", "url": "/api/products/:id", "method": "DELETE",
"middleware": "HIX_MwJwt,HIX_MwJwtScope", "scope": "delete:products" }
With a token that carries "scope" => "read:products write:orders":
| Route | Result |
|---|---|
GET /api/products (read:products) |
✅ allows |
POST /api/orders (write:orders) |
✅ allows |
DELETE /api/products/42 (delete:products) |
❌ 403—missing scope |
Order matters:
HIX_MwJwtfirst (leaves payload in hData),HIX_MwJwtScopesecond (reads it).
Multiple keys—HIX_MwJwtFactory¶
If different routes use different signing keys (for example, one set for public API and another for internal API):
LOCAL bMwApi := HIX_MwJwtFactory( "public_key" )
LOCAL bMwIntern := HIX_MwJwtFactory( "internal_key" )
oSrv:AddRouteGet( "pub", "/api/pub", bAction, bMwApi )
oSrv:AddRouteGet( "intern", "/admin/data", bAction, bMwIntern )
Validate / decode manually¶
Useful for tokens outside the pipeline (for example, validating one received via WebSocket):
LOCAL hPayload := HIX_JwtValidate( cToken )
IF hPayload == NIL
// invalid signature or expired
RETURN .F.
ENDIF
? hPayload[ "user_id" ], hPayload[ "exp" ]
Refresh tokens—basic pattern¶
JWT cannot be invalidated before exp. For long-duration tokens without losing security, use two tokens:
| Token | TTL | Use |
|---|---|---|
| Access token | short (15 min) | Goes in every request Authorization: Bearer ... |
| Refresh token | long (7-30 days) | Only travels to /refresh endpoint to issue a new access token |
// POST /api/refresh
FUNCTION Main()
LOCAL cRefresh := UPost( "refresh_token", "" )
LOCAL hPayload := HIX_JwtValidate( cRefresh, "refresh_key" )
IF hPayload == NIL
RETURN USendJson( { "error" => "invalid_refresh" }, 401 )
ENDIF
USendJson( { ;
"token" => HIX_JwtEncode( { "user_id" => hPayload["user_id"] } ) ;
} )
RETURN
JWT vs Session—quick table¶
| JWT | Session | |
|---|---|---|
| Server storage | ❌ No | ✅ Yes (memory/file) |
| Immediate invalidation | ❌ Wait for exp or blacklist |
✅ Destroy() |
| Horizontal scaling | ✅ No shared state | ⚠️ Needs storage or session affinity |
| Cross-domain / mobile | ✅ Bearer header | ❌ Cookie tied to domain |
| CSRF | ❌ Not applicable (not a cookie) | ⚠️ Mandatory |
| Size per request | ~500-1000 bytes | ~50 bytes (only SID) |
| Revoke issued tokens | ❌ Difficult without blacklist | ✅ Easy |
Best practices¶
- Long and rotatable secret key. Minimum 32 random bytes.
Change it across environments (
dev/prod). - Short expiration. 15-60 min for access, refresh token separately. Eternal JWTs are a security hole.
- Don't put sensitive data in the payload. The payload is base64, not encrypted—anyone can read it. It's only tamper-proof.
- HTTPS always. The Bearer travels in every request—without SSL anyone can intercept it.
- Don't mix JWT with cookies. If you go JWT, use only the
Authorizationheader—putting the token in a cookie brings back the CSRF problems JWT avoids. - Blacklist for logout. If you need to invalidate before
exp, save the revokedjti(JWT ID) in Redis and check them in the middleware.
Internal architecture¶
Since version 2026-07-14, JWT code is split into two files with well-defined responsibilities:
| File | Layer | Content |
|---|---|---|
src/hix_jwt.prg |
Engine (pure) | HIX_JwtEncode, HIX_JwtValidate, HIX_MwJwtSetup, HIX_JwtDefaultKey, HIX_JwtDefaultExp, and private helpers for HMAC-SHA256 and Base64Url signing. No router dependencies. |
src/mw/hix_mw_jwt.prg |
Middleware | HIX_MwJwt, HIX_MwJwtFactory, HIX_MwJwtScope. Pipeline only: extracts Bearer, invokes the engine, and writes oCtx:hData["jwt"]. |
Advantages of the split:
- The engine can be reused outside the pipeline (CLI, workers, validating tokens received via WebSocket) without loading the middleware.
- Configuration STATICs (
s_cJwtKey,s_nJwtExpSec) live in the engine. The middleware consults them viaHIX_JwtDefaultKey()/HIX_JwtDefaultExp()(STATICs in Harbour are file-scoped). - Changes to the pipeline (403/401 handling, telemetry) don't force recompiling the engine, and vice versa.
The public API is unchanged:
HIX_MwJwtSetup,HIX_JwtEncode,HIX_JwtValidate,HIX_MwJwt,HIX_MwJwtFactory, andHIX_MwJwtScopekeep the same name and signature as before the split.