andy.dev

🔐 JWT Authentication en Golang: implementación completa para APIs seguras

JWT Authentication

Cuando una API comienza a ser utilizada por aplicaciones web, móviles o servicios internos, tarde o temprano aparece la misma necesidad: identificar quién realiza cada petición sin mantener sesiones en memoria.

Ahí es donde JWT (JSON Web Token) suele convertirse en una de las soluciones más utilizadas. Su principal ventaja es que el servidor no necesita almacenar el estado de cada usuario autenticado. Cada petición lleva consigo la información necesaria para validar la identidad del cliente.

Aunque el concepto parece sencillo, una implementación incorrecta puede introducir vulnerabilidades difíciles de detectar. Es común encontrar APIs que aceptan cualquier algoritmo de firma, tokens sin expiración o secretos expuestos en el repositorio.

Este artículo desarrolla una implementación completa utilizando Go, explicando qué ocurre en cada etapa y mostrando una estructura fácil de mantener.


¿Qué problema resuelve JWT?

Imaginemos una API que administra pedidos.

Sin autenticación cualquier persona podría ejecutar:

GET /orders
DELETE /orders/15
POST /orders

No existe forma de saber quién realizó la petición.

Con JWT el flujo cambia.

Usuario
   │
   │ Login
   ▼
Servidor
   │
   │ Genera JWT firmado
   ▼
Cliente
   │
   │ Authorization: Bearer <token>
   ▼
API
   │
   │ Verifica firma
   ▼
Acceso permitido

El token contiene información del usuario y una firma digital que impide modificarlo sin conocer la clave secreta.


Flujo completo

flowchart LR

A[Login]
B[Validar usuario]
C[Generar JWT]
D[Cliente almacena Token]
E[Petición protegida]
F[Middleware]
G[Validar firma]
H[Endpoint]

A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G -->|Correcto| H
G -->|Inválido| X[401 Unauthorized]

Estructura del proyecto

jwt-api/

├── main.go
├── handlers
│   └── auth.go
├── middleware
│   └── jwt.go
├── models
│   └── user.go
├── utils
│   └── jwt.go
├── go.mod

La separación evita mezclar lógica de autenticación con la lógica del negocio.


Instalación

Inicializamos el proyecto.

go mod init jwt-api

Instalamos las dependencias.

go get github.com/golang-jwt/jwt/v5
go get github.com/gin-gonic/gin

Definiendo el modelo

package models

type User struct {
	ID       int
	Username string
	Password string
}

Para simplificar el ejemplo utilizaremos un usuario fijo.

En un proyecto real estos datos provendrían de una base de datos y la contraseña estaría almacenada con bcrypt.


Generando un JWT

Creamos utils/jwt.go

package utils

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
)

var SecretKey = []byte("super-secret-key")

type Claims struct {
	UserID int `json:"user_id"`
	jwt.RegisteredClaims
}

func GenerateToken(userID int) (string, error) {

	claims := Claims{
		UserID: userID,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	return token.SignedString(SecretKey)
}

El token incluirá:

  • ID del usuario
  • Fecha de emisión
  • Fecha de expiración

La firma se realiza utilizando HS256.


Login

Creamos handlers/auth.go

package handlers

import (
	"net/http"

	"github.com/gin-gonic/gin"

	"jwt-api/utils"
)

type LoginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

func Login(c *gin.Context) {

	var req LoginRequest

	if err := c.ShouldBindJSON(&req); err != nil {

		c.JSON(http.StatusBadRequest, gin.H{
			"error": "JSON inválido",
		})

		return
	}

	if req.Username != "admin" || req.Password != "123456" {

		c.JSON(http.StatusUnauthorized, gin.H{
			"error": "Credenciales inválidas",
		})

		return
	}

	token, err := utils.GenerateToken(1)

	if err != nil {

		c.JSON(http.StatusInternalServerError, gin.H{
			"error": err.Error(),
		})

		return
	}

	c.JSON(http.StatusOK, gin.H{
		"token": token,
	})
}

Una vez autenticado el usuario se devuelve un JWT.

Respuesta:

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

Middleware para validar el token

Creamos middleware/jwt.go

package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"

	"jwt-api/utils"
)

func JWTMiddleware() gin.HandlerFunc {

	return func(c *gin.Context) {

		auth := c.GetHeader("Authorization")

		if auth == "" {

			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "Token requerido",
			})

			return
		}

		tokenString := strings.TrimPrefix(auth, "Bearer ")

		token, err := jwt.ParseWithClaims(
			tokenString,
			&utils.Claims{},
			func(token *jwt.Token) (interface{}, error) {
				return utils.SecretKey, nil
			},
		)

		if err != nil || !token.Valid {

			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "Token inválido",
			})

			return
		}

		c.Next()
	}
}

Este middleware intercepta todas las peticiones protegidas antes de que lleguen al controlador.


Endpoint protegido

func Profile(c *gin.Context) {

	c.JSON(200, gin.H{
		"message": "Acceso autorizado",
	})
}

Configurando Gin

package main

import (
	"github.com/gin-gonic/gin"

	"jwt-api/handlers"
	"jwt-api/middleware"
)

func main() {

	router := gin.Default()

	router.POST("/login", handlers.Login)

	protected := router.Group("/api")

	protected.Use(middleware.JWTMiddleware())

	{
		protected.GET("/profile", handlers.Profile)
	}

	router.Run(":8080")
}

Probando el login

Petición:

POST /login

Body:

{
  "username": "admin",
  "password": "123456"
}

Respuesta:

{
  "token":"eyJhbGc..."
}

Consumir un endpoint protegido

GET /api/profile

Header:

Authorization: Bearer eyJhbGc...

Respuesta:

{
    "message":"Acceso autorizado"
}

¿Qué contiene realmente un JWT?

Un JWT posee tres partes.

HEADER.PAYLOAD.SIGNATURE

Ejemplo:

xxxxx.yyyyy.zzzzz

Header

{
  "alg":"HS256",
  "typ":"JWT"
}

Payload

{
   "user_id":1,
   "exp":1740000000
}

Signature

HMACSHA256(
 base64(header)+ "." + base64(payload),
 secret
)

La firma garantiza que nadie pueda modificar el contenido sin conocer la clave.


¿Por qué no guardar información sensible?

Un error muy frecuente consiste en almacenar información privada dentro del payload.

Por ejemplo:

{
    "password":"123456",
    "creditCard":"1111-2222"
}

Aunque el token esté firmado, el contenido puede leerse fácilmente porque únicamente está codificado en Base64.

JWT no cifra información.

Solo garantiza integridad.


Expiración del token

Muchos proyectos generan tokens que nunca expiran.

Eso implica que un token robado seguirá siendo válido incluso meses después.

Lo habitual es trabajar con tiempos como:

TipoDuración
Access Token15 minutos
API interna1 hora
Dashboard2 horas
Refresh Token7-30 días

El Access Token debe ser corto.

El Refresh Token se utiliza únicamente para solicitar nuevos Access Tokens.


Variables de entorno

Nunca conviene escribir el secreto directamente dentro del código.

En lugar de esto:

var SecretKey = []byte("super-secret-key")

Es preferible:

secret := os.Getenv("JWT_SECRET")

Archivo .env

JWT_SECRET=5aD92sP@81Jkl...

Si el repositorio termina siendo público, la clave no quedará expuesta.


Hash de contraseñas

Otro error habitual consiste en comparar contraseñas en texto plano.

Incorrecto:

if password == user.Password

Lo recomendable es utilizar bcrypt.

err := bcrypt.CompareHashAndPassword(
    []byte(user.Password),
    []byte(password),
)

Así la contraseña nunca queda almacenada de forma legible.


Middleware con contexto

Una mejora bastante útil consiste en guardar el usuario autenticado dentro del contexto.

claims := token.Claims.(*utils.Claims)

c.Set("userID", claims.UserID)

Más adelante cualquier controlador puede recuperarlo.

userID, _ := c.Get("userID")

Esto evita volver a consultar el token continuamente.


Diagrama del middleware

flowchart TD

A[Petición HTTP]
B[Leer Authorization]
C{Existe?}
D[401]
E[Validar JWT]
F{Firma válida?}
G[401]
H[Guardar usuario]
I[Continuar endpoint]

A --> B
B --> C
C -- No --> D
C -- Sí --> E
E --> F
F -- No --> G
F -- Sí --> H
H --> I

Buenas prácticas

  • Nunca almacenes contraseñas dentro del JWT.
  • Usa HTTPS en todos los entornos productivos.
  • Mantén el tiempo de vida del Access Token lo más corto posible.
  • Guarda el secreto en variables de entorno o gestores de secretos.
  • Valida siempre el algoritmo de firma esperado.
  • Implementa Refresh Tokens cuando la aplicación requiera sesiones largas.
  • Revoca tokens comprometidos mediante listas negras o rotación de claves.
  • Evita incluir información que pueda cambiar con frecuencia; el JWT debe contener únicamente los datos mínimos necesarios para identificar al usuario.
  • Registra los intentos de autenticación fallidos para facilitar la detección de ataques de fuerza bruta.