La seguridad en sistemas distribuidos no reside en un solo mecanismo, sino en la correcta implementación de capas: la identidad (JWT), el transporte (TLS) y la integridad de la comparación de datos (Constant Time). Cuando manejas tokens JWT, el parseo debe ser estricto; no basta con verificar la firma, hay que forzar el algoritmo esperado para evitar ataques de sustitución de algoritmos (como el bypass none o el cambio de RS256 a HS256). En el transporte, configurar un tls.Config con un MinVersion adecuado es la diferencia entre una conexión segura y una vulnerable a ataques de downgrade. A nivel de código, incluso la comparación de un secreto mediante == es un riesgo, ya que el compilador y el CPU optimizan la salida temprana (short-circuiting) cuando los bytes no coinciden, permitiendo que un atacante use ataques de temporización (timing attacks) para deducir el valor byte a byte. Para mitigar esto, usamos subtle.ConstantTimeCompare. Finalmente, la validación de input debe ser una frontera rígida: si un dato no cumple con el tipo, rango o formato esperado, el flujo debe morir antes de tocar la lógica de negocio. Implementar esto correctamente garantiza que la superficie de ataque sea mínima.
package main
import (
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5" // [disponible desde su lanzamiento]
)
// UserClaims define la estructura de nuestra identidad validada.
type UserClaims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Validate realiza una validación de entrada estricta sobre los datos.
func (u *UserClaims) Validate() error {
// Nunca confíes en que el ID sea positivo solo porque el tipo es int.
if u.UserID <= 0 {
return errors.New("invalid user id: must be positive")
}
if u.Role != "admin" && u.Role != "user" {
return errors.New("invalid role")
}
return nil
}
// VerifyToken parsea y valida el JWT asegurando que el algoritmo sea el esperado.
func VerifyToken(tokenString string, secret []byte) (*UserClaims, error) {
claims := &UserClaims{}
// ParseWithClaims es la forma segura de extraer datos en claims personalizados.
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
// CRÍTICO: Validar explícitamente el método de firma para evitar ataques "none"
// o ataques de confusión de algoritmos (RS256 vs HS256).
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("token inválido o expirado")
}
// Validamos la lógica de negocio sobre los claims extraídos.
if err := claims.Validate(); err != nil {
return nil, fmt.Errorf("claims no válidos: %w", err)
}
return claims, nil
}
// SecureCompare evita ataques de timing comparando secretos en tiempo constante.
func SecureCompare(input, secret string) bool {
// Convertimos a []byte para usar la librería subtle.
// ConstantTimeCompare tarda lo mismo sin importar cuántos bytes coincidan.
return subtle.ConstantTimeCompare([]byte(input), []byte(secret)) == 1
}
func main() {
// Configuración de seguridad simulada
secretKey := []byte("una-clave-muy-secreta-y-larga")
userSecret := "password123"
userAttempt := "password123"
// 1. Simulación de JWT válido
token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{
UserID: 42,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
tokenString, _ := token.SignedString(secretKey)
claims, err := VerifyToken(tokenString, secretKey)
if err != nil {
fmt.Printf("Error validando token: %v\n", err)
} else {
fmt.Printf("Token válido para usuario: %d\n", claims.UserID)
}
// 2. Comparación segura de credenciales
if SecureCompare(userAttempt, userSecret) {
fmt.Println("Credenciales correctas (comparación segura)")
}
// 3. Nota sobre TLS: En un servidor real, usaríamos:
// tlsConfig := &tls.Config{
// MinVersion: tls.VersionTLS12, // Evita protocolos obsoletos
// CipherSuites: []uint16{...}, // Forzar suites fuertes
// }
fmt.Println("Configuración de TLS lista (mínimo TLS 1.2)")
}
Análisis del flujo de seguridad
En el ejemplo, la función VerifyToken no se limita a confiar en lo que dice el header del JWT. La clave está en el KeyFunc pasado a jwt.ParseWithClaims: al verificar que t.Method sea un *jwt.SigningMethodHMAC, bloqueamos cualquier intento de un atacante de enviar un token con alg: "none" o de intentar usar una clave pública para validar una firma HMAC.
La estructura UserClaims implementa un método Validate. Esto es vital: el parser de JWT solo garantiza que la firma es correcta y que el JSON es válido, pero no que el UserID sea un número lógico para tu sistema o que el Role no sea una cadena maliciosa. Validar el rango y el formato inmediatamente después del parseo es la primera línea de defensa.
Para la comparación de credenciales, SecureCompare utiliza subtle.ConstantTimeCompare. A diferencia de un if input == secret, que devuelve false tan pronto como encuentra el primer caracter distinto (permitiendo medir el tiempo de respuesta), subtle recorre toda la longitud de los slices de forma determinista, evitando fugas de información por canales laterales.
El error frecuente
Un error crítico en Go es realizar validaciones de seguridad utilizando comparaciones estándar de strings para secretos o hashes:
// ¡MAL! Vulnerable a timing attacks
if inputToken == secretToken {
// ...
}
Aunque parezca insignificante, un atacante con suficiente precisión de red puede medir los nanosegundos de diferencia entre una comparación que falla en el primer byte y una que falla en el décimo. Esto permite reconstruir el secretToken mediante fuerza bruta dirigida por tiempo. Siempre usa crypto/subtle para comparar hashes, tokens de autenticación o cualquier dato sensible.
N° 217