🎫 JWT - JSON Web Token¶
¿Qué es?¶
Un JWT es un token autocontenido y firmado que el servidor emite al hacer login y el cliente envía en cada request siguiente.
- Autocontenido: lleva dentro toda la información del usuario
(
user_id,role, etc.). El servidor no guarda nada entre requests. - Firmado: HIX usa HMAC-SHA256 con una clave secreta. Si el cliente altera un solo byte, la firma deja de cuadrar y se rechaza.
- Stateless: dos servidores con la misma clave validan el mismo token → escala horizontalmente sin storage compartido.
header.payload.signature
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 {"typ":"JWT","alg":"HS256"}
.eyJ1c2VyX2lkIjoiNDIiLCJyb2xlIjoi... {"user_id":"42","role":"admin","exp":...}
.aBcD3f9eGgHhIi... HMAC-SHA256(header.payload, secret)
Cuándo usarlo¶
| Caso de uso | JWT |
|---|---|
| API REST stateless | ✅ Sí - patrón canónico |
| App móvil que llama a una API | ✅ Sí |
| Microservicios con tokens compartidos | ✅ Sí |
| SPA llamando a un backend separado | ✅ Sí |
| App web tradicional con login form | ❌ Usa Sesiones |
| Tokens de un solo uso (descarga, reset password) | ⚠️ Sí, con exp cortito |
JWT vs Sesión: el JWT no necesita storage en el servidor pero no se puede invalidar antes de que expire. Sesión es lo contrario: requiere storage pero puedes destruir el SID y echar al cliente al instante.
Setup¶
HIX_MwJwtSetup( ;
"clave_super_secreta_y_larga", ; // cKey - HMAC secret
3600 ) // nExpSecs - TTL del token (1h)
Llamarlo antes de oSrv:Start(). Si no lo configuras, HIX usa
hix-secret-key por defecto (⚠️ inseguro).
Emitir un token en /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 añade automáticamente las claims estándar:
| Claim | Valor |
|---|---|
iss |
"HIX" |
iat |
timestamp Unix de emisión |
exp |
iat + nExpSecs |
Lo que añadas tú (user_id, scope, role, ...) viaja junto.
Proteger una ruta¶
// Pipeline: validación JWT -> 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" }
El cliente debe enviar:
HIX_MwJwt valida la firma + exp, deja el payload en
oCtx:hData["jwt"] y continúa. Si el token falta o es inválido →
401.
Leer el payload en el 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 - autorización por operación¶
HIX_MwJwtScope exige que la claim scope del token contenga todos los
tokens (separados por espacio) que la ruta declara como necesarios:
{ "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" }
Con un token que lleve "scope" => "read:products write:orders":
| Ruta | Resultado |
|---|---|
GET /api/products (read:products) |
✅ permite |
POST /api/orders (write:orders) |
✅ permite |
DELETE /api/products/42 (delete:products) |
❌ 403 - falta el scope |
El orden importa:
HIX_MwJwtprimero (deja el payload en hData),HIX_MwJwtScopedespués (lo lee).
Múltiples claves - HIX_MwJwtFactory¶
Si distintas rutas usan distintas claves de firma (por ejemplo, un set para API pública y otro para API interna):
LOCAL bMwApi := HIX_MwJwtFactory( "clave_publica" )
LOCAL bMwIntern := HIX_MwJwtFactory( "clave_interna" )
oSrv:AddRouteGet( "pub", "/api/pub", bAction, bMwApi )
oSrv:AddRouteGet( "intern", "/admin/data", bAction, bMwIntern )
Validar / decodificar a mano¶
Útil para tokens fuera del pipeline (por ejemplo, validar uno recibido por WebSocket):
LOCAL hPayload := HIX_JwtValidate( cToken )
IF hPayload == NIL
// firma inválida o expirado
RETURN .F.
ENDIF
? hPayload[ "user_id" ], hPayload[ "exp" ]
Refresh tokens - patrón básico¶
JWT no se puede invalidar antes de exp. Para tokens de larga duración
sin perder seguridad, usa dos tokens:
| Token | TTL | Uso |
|---|---|---|
| Access token | corto (15 min) | Va en cada request Authorization: Bearer ... |
| Refresh token | largo (7-30 días) | Solo viaja al endpoint /refresh para emitir un nuevo access |
// POST /api/refresh
FUNCTION Main()
LOCAL cRefresh := UPost( "refresh_token", "" )
LOCAL hPayload := HIX_JwtValidate( cRefresh, "clave_refresh" )
IF hPayload == NIL
RETURN USendJson( { "error" => "invalid_refresh" }, 401 )
ENDIF
USendJson( { ;
"token" => HIX_JwtEncode( { "user_id" => hPayload["user_id"] } ) ;
} )
RETURN
JWT vs Sesión - tabla rápida¶
| JWT | Sesión | |
|---|---|---|
| Storage en servidor | ❌ No | ✅ Sí (memoria/fichero) |
| Invalidación inmediata | ❌ Espera exp o lista negra |
✅ Destroy() |
| Escalado horizontal | ✅ Sin estado compartido | ⚠️ Necesita storage o session affinity |
| Cross-domain / móvil | ✅ Bearer header | ❌ Cookie atada al dominio |
| CSRF | ❌ No aplica (no es cookie) | ⚠️ Obligatorio |
| Tamaño por request | ~500-1000 bytes | ~50 bytes (solo SID) |
| Revocar tokens emitidos | ❌ Difícil sin lista negra | ✅ Fácil |
Buenas prácticas¶
- Clave secreta larga y rotable. Mínimo 32 bytes aleatorios.
Cámbiala por entornos (
dev/prod). - Expiración corta. 15-60 min para acceso, refresh largo aparte. JWT eternos son un agujero de seguridad.
- No metas datos sensibles en el payload. El payload es base64, no encriptado - cualquiera puede leerlo. Solo es a prueba de manipulación.
- HTTPS siempre. El Bearer viaja en cada request - sin SSL cualquiera lo intercepta.
- No mezcles JWT con cookies. Si vas a JWT, usa solo el header
Authorization- meter el token en cookie te trae los problemas de CSRF que el JWT evitaba. - Lista negra para logout. Si necesitas invalidar antes de
exp, guarda en Redis losjti(JWT ID) revocados y compruébalos en el middleware.
Arquitectura interna¶
Desde la versión 2026-07-14 el código JWT está separado en dos ficheros con responsabilidades bien definidas:
| Fichero | Capa | Contenido |
|---|---|---|
src/hix_jwt.prg |
Engine (puro) | HIX_JwtEncode, HIX_JwtValidate, HIX_MwJwtSetup, HIX_JwtDefaultKey, HIX_JwtDefaultExp y helpers privados de firma HMAC-SHA256 y Base64Url. Sin dependencias del router. |
src/mw/hix_mw_jwt.prg |
Middleware | HIX_MwJwt, HIX_MwJwtFactory, HIX_MwJwtScope. Solo pipeline: extrae Bearer, invoca el engine y escribe oCtx:hData["jwt"]. |
Ventajas del split:
- El engine se puede reutilizar fuera del pipeline (CLI, workers, validar tokens recibidos por WebSocket) sin cargar el middleware.
- Los STATIC de configuración (
s_cJwtKey,s_nJwtExpSec) viven en el engine. El middleware los consulta víaHIX_JwtDefaultKey()/HIX_JwtDefaultExp()(los STATIC en Harbour son de scope por fichero). - Cambios en el pipeline (403/401 handling, telemetría) no obligan a recompilar el engine y viceversa.
La API pública no cambia:
HIX_MwJwtSetup,HIX_JwtEncode,HIX_JwtValidate,HIX_MwJwt,HIX_MwJwtFactoryyHIX_MwJwtScopemantienen el mismo nombre y firma que antes del split.