Autenticação JWT com Go: Guia Prático

JWT (JSON Web Token) é o padrão mais usado para autenticação em APIs modernas. Com Go, implementar JWT é direto e performático. Neste tutorial, vamos construir um sistema completo de autenticação com login, tokens de acesso, refresh tokens e middleware de proteção de rotas.


O que é JWT e Como Funciona?

Um JWT é uma string composta por três partes separadas por pontos:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
|___ Header ___|.___ Payload ___|._______ Signature _______|

Header: algoritmo de assinatura e tipo do token

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

Payload: dados do usuário (claims)

{
  "sub": "1234567890",
  "nome": "João",
  "admin": true,
  "exp": 1700000000
}

Signature: garante que o token não foi alterado

HMAC-SHA256(base64(header) + "." + base64(payload), secret)

O fluxo funciona assim:

  1. Cliente envia credenciais (email + senha)
  2. Servidor valida e retorna um JWT
  3. Cliente envia o JWT no header Authorization: Bearer <token> em cada requisição
  4. Servidor valida o token e libera (ou bloqueia) o acesso

Setup do Projeto

mkdir auth-api
cd auth-api
go mod init auth-api
go get github.com/golang-jwt/jwt/v5

Estrutura do projeto:

auth-api/
├── main.go
├── auth/
│   ├── jwt.go         # Criação e validação de tokens
│   └── middleware.go   # Middleware de autenticação
├── handler/
│   └── handler.go     # Handlers HTTP
├── model/
│   └── user.go        # Modelo de usuário
├── go.mod
└── go.sum

Modelo de Usuário

// model/user.go
package model

import "time"

// Usuario representa um usuário do sistema
type Usuario struct {
    ID       int64  `json:"id"`
    Nome     string `json:"nome"`
    Email    string `json:"email"`
    Senha    string `json:"-"` // Nunca retorna a senha no JSON
    Ativo    bool   `json:"ativo"`
    CriadoEm time.Time `json:"criado_em"`
}

// LoginRequest representa o corpo da requisição de login
type LoginRequest struct {
    Email string `json:"email"`
    Senha string `json:"senha"`
}

// TokenResponse é a resposta retornada após login bem-sucedido
type TokenResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int64  `json:"expires_in"`
}

// ErrorResponse padroniza respostas de erro
type ErrorResponse struct {
    Erro    string `json:"erro"`
    Codigo  int    `json:"codigo"`
}

Criando e Validando Tokens JWT

Aqui está o coração do sistema de autenticação:

// auth/jwt.go
package auth

import (
    "errors"
    "fmt"
    "os"
    "time"

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

// Claims personalizados que incluem dados do usuário
type Claims struct {
    UsuarioID int64  `json:"usuario_id"`
    Email     string `json:"email"`
    Nome      string `json:"nome"`
    jwt.RegisteredClaims
}

// RefreshClaims para o refresh token (dados mínimos)
type RefreshClaims struct {
    UsuarioID int64 `json:"usuario_id"`
    jwt.RegisteredClaims
}

// Durações dos tokens
const (
    AccessTokenDuracao  = 15 * time.Minute  // Access token: 15 minutos
    RefreshTokenDuracao = 7 * 24 * time.Hour // Refresh token: 7 dias
)

// Erros comuns de autenticação
var (
    ErrTokenExpirado  = errors.New("token expirado")
    ErrTokenInvalido  = errors.New("token inválido")
    ErrSecretAusente  = errors.New("JWT_SECRET não configurado")
)

// getSecret retorna a chave secreta para assinar tokens
func getSecret() ([]byte, error) {
    secret := os.Getenv("JWT_SECRET")
    if secret == "" {
        return nil, ErrSecretAusente
    }
    // Em produção, use pelo menos 32 caracteres aleatórios
    return []byte(secret), nil
}

// GerarAccessToken cria um novo access token para o usuário
func GerarAccessToken(usuarioID int64, email, nome string) (string, error) {
    secret, err := getSecret()
    if err != nil {
        return "", err
    }

    agora := time.Now()

    claims := Claims{
        UsuarioID: usuarioID,
        Email:     email,
        Nome:      nome,
        RegisteredClaims: jwt.RegisteredClaims{
            // Emissor do token
            Issuer: "auth-api",
            // Para quem o token foi emitido
            Subject: fmt.Sprintf("%d", usuarioID),
            // Quando o token foi emitido
            IssuedAt: jwt.NewNumericDate(agora),
            // Quando o token expira
            ExpiresAt: jwt.NewNumericDate(agora.Add(AccessTokenDuracao)),
            // Não usar antes desta data
            NotBefore: jwt.NewNumericDate(agora),
        },
    }

    // Cria o token com o algoritmo HS256
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

    // Assina o token com o secret
    tokenString, err := token.SignedString(secret)
    if err != nil {
        return "", fmt.Errorf("erro ao assinar token: %w", err)
    }

    return tokenString, nil
}

// GerarRefreshToken cria um refresh token com vida longa
func GerarRefreshToken(usuarioID int64) (string, error) {
    secret, err := getSecret()
    if err != nil {
        return "", err
    }

    agora := time.Now()

    claims := RefreshClaims{
        UsuarioID: usuarioID,
        RegisteredClaims: jwt.RegisteredClaims{
            Issuer:    "auth-api",
            Subject:   fmt.Sprintf("%d", usuarioID),
            IssuedAt:  jwt.NewNumericDate(agora),
            ExpiresAt: jwt.NewNumericDate(agora.Add(RefreshTokenDuracao)),
            NotBefore: jwt.NewNumericDate(agora),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secret)
}

// ValidarAccessToken verifica e decodifica um access token
func ValidarAccessToken(tokenString string) (*Claims, error) {
    secret, err := getSecret()
    if err != nil {
        return nil, err
    }

    // Faz o parse do token e valida a assinatura
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        // Verifica se o algoritmo é o esperado (previne ataques de troca de algoritmo)
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("algoritmo inesperado: %v", token.Header["alg"])
        }
        return secret, nil
    })

    if err != nil {
        // Verifica se o erro é de expiração
        if errors.Is(err, jwt.ErrTokenExpired) {
            return nil, ErrTokenExpirado
        }
        return nil, ErrTokenInvalido
    }

    // Extrai os claims do token
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, ErrTokenInvalido
    }

    return claims, nil
}

// ValidarRefreshToken verifica e decodifica um refresh token
func ValidarRefreshToken(tokenString string) (*RefreshClaims, error) {
    secret, err := getSecret()
    if err != nil {
        return nil, err
    }

    token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("algoritmo inesperado: %v", token.Header["alg"])
        }
        return secret, nil
    })

    if err != nil {
        if errors.Is(err, jwt.ErrTokenExpired) {
            return nil, ErrTokenExpirado
        }
        return nil, ErrTokenInvalido
    }

    claims, ok := token.Claims.(*RefreshClaims)
    if !ok || !token.Valid {
        return nil, ErrTokenInvalido
    }

    return claims, nil
}

Middleware de Autenticação

O middleware intercepta requisições e verifica se o token é válido:

// auth/middleware.go
package auth

import (
    "context"
    "encoding/json"
    "net/http"
    "strings"
)

// Chave para armazenar claims no contexto da requisição
type contextKey string

const ClaimsContextKey contextKey = "claims"

// Middleware verifica o JWT em cada requisição protegida
func Middleware(proximo http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")

        // Extrai o token do header Authorization
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            responderErro(w, "Token de autenticação não fornecido", http.StatusUnauthorized)
            return
        }

        // O formato esperado é: "Bearer <token>"
        partes := strings.SplitN(authHeader, " ", 2)
        if len(partes) != 2 || !strings.EqualFold(partes[0], "bearer") {
            responderErro(w, "Formato do token inválido. Use: Bearer <token>", http.StatusUnauthorized)
            return
        }

        tokenString := partes[1]

        // Valida o token
        claims, err := ValidarAccessToken(tokenString)
        if err != nil {
            switch err {
            case ErrTokenExpirado:
                responderErro(w, "Token expirado. Faça refresh ou login novamente", http.StatusUnauthorized)
            default:
                responderErro(w, "Token inválido", http.StatusUnauthorized)
            }
            return
        }

        // Adiciona os claims ao contexto da requisição
        ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)

        // Passa para o próximo handler com o contexto atualizado
        proximo.ServeHTTP(w, r.WithContext(ctx))
    })
}

// ClaimsDaRequisicao extrai os claims do contexto
func ClaimsDaRequisicao(r *http.Request) (*Claims, bool) {
    claims, ok := r.Context().Value(ClaimsContextKey).(*Claims)
    return claims, ok
}

// responderErro envia uma resposta de erro padronizada
func responderErro(w http.ResponseWriter, mensagem string, statusCode int) {
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "erro":   mensagem,
        "codigo": statusCode,
    })
}

Handlers HTTP: Login, Refresh e Rotas Protegidas

// handler/handler.go
package handler

import (
    "encoding/json"
    "net/http"

    "auth-api/auth"
    "auth-api/model"

    "golang.org/x/crypto/bcrypt"
)

// Simulação de banco de dados em memória
var usuarios = map[string]model.Usuario{
    "joao@email.com": {
        ID:    1,
        Nome:  "João Silva",
        Email: "joao@email.com",
        // Senha: "senha123" hasheada com bcrypt
        Senha: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
        Ativo: true,
    },
}

// Login autentica o usuário e retorna os tokens
func Login(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    // Aceita apenas POST
    if r.Method != http.MethodPost {
        http.Error(w, `{"erro":"método não permitido"}`, http.StatusMethodNotAllowed)
        return
    }

    // Decodifica o corpo da requisição
    var req model.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Corpo da requisição inválido",
            Codigo: http.StatusBadRequest,
        })
        return
    }

    // Validação básica
    if req.Email == "" || req.Senha == "" {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Email e senha são obrigatórios",
            Codigo: http.StatusBadRequest,
        })
        return
    }

    // Busca o usuário (em produção, busque no banco de dados)
    usuario, existe := usuarios[req.Email]
    if !existe {
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Credenciais inválidas",
            Codigo: http.StatusUnauthorized,
        })
        return
    }

    // Verifica a senha com bcrypt
    if err := bcrypt.CompareHashAndPassword([]byte(usuario.Senha), []byte(req.Senha)); err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Credenciais inválidas",
            Codigo: http.StatusUnauthorized,
        })
        return
    }

    // Gera os tokens
    accessToken, err := auth.GerarAccessToken(usuario.ID, usuario.Email, usuario.Nome)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Erro ao gerar token",
            Codigo: http.StatusInternalServerError,
        })
        return
    }

    refreshToken, err := auth.GerarRefreshToken(usuario.ID)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Erro ao gerar refresh token",
            Codigo: http.StatusInternalServerError,
        })
        return
    }

    // Retorna os tokens
    resp := model.TokenResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    900, // 15 minutos em segundos
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(resp)
}

// Refresh gera um novo access token usando o refresh token
func Refresh(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    if r.Method != http.MethodPost {
        http.Error(w, `{"erro":"método não permitido"}`, http.StatusMethodNotAllowed)
        return
    }

    var req struct {
        RefreshToken string `json:"refresh_token"`
    }

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Corpo da requisição inválido",
            Codigo: http.StatusBadRequest,
        })
        return
    }

    // Valida o refresh token
    claims, err := auth.ValidarRefreshToken(req.RefreshToken)
    if err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Refresh token inválido ou expirado",
            Codigo: http.StatusUnauthorized,
        })
        return
    }

    // Busca o usuário para obter dados atualizados
    // Em produção, busque no banco pelo ID
    var usuario model.Usuario
    for _, u := range usuarios {
        if u.ID == claims.UsuarioID {
            usuario = u
            break
        }
    }

    // Gera novo access token
    novoAccessToken, err := auth.GerarAccessToken(usuario.ID, usuario.Email, usuario.Nome)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Erro ao gerar novo token",
            Codigo: http.StatusInternalServerError,
        })
        return
    }

    json.NewEncoder(w).Encode(model.TokenResponse{
        AccessToken: novoAccessToken,
        TokenType:   "Bearer",
        ExpiresIn:   900,
    })
}

// Perfil retorna os dados do usuário autenticado (rota protegida)
func Perfil(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    // Extrai os claims do contexto (populados pelo middleware)
    claims, ok := auth.ClaimsDaRequisicao(r)
    if !ok {
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(model.ErrorResponse{
            Erro:   "Não autenticado",
            Codigo: http.StatusUnauthorized,
        })
        return
    }

    // Retorna dados do usuário
    json.NewEncoder(w).Encode(map[string]interface{}{
        "usuario_id": claims.UsuarioID,
        "nome":       claims.Nome,
        "email":      claims.Email,
        "mensagem":   "Bem-vindo à área protegida!",
    })
}

Juntando Tudo: main.go

// main.go
package main

import (
    "log"
    "net/http"

    "auth-api/auth"
    "auth-api/handler"
)

func main() {
    mux := http.NewServeMux()

    // ========================================
    // Rotas públicas (sem autenticação)
    // ========================================
    mux.HandleFunc("/login", handler.Login)
    mux.HandleFunc("/refresh", handler.Refresh)
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    // ========================================
    // Rotas protegidas (com JWT middleware)
    // ========================================
    rotasProtegidas := http.NewServeMux()
    rotasProtegidas.HandleFunc("/perfil", handler.Perfil)
    rotasProtegidas.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
        claims, _ := auth.ClaimsDaRequisicao(r)
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"mensagem":"Dados do dashboard para ` + claims.Nome + `"}`))
    })

    // Aplica o middleware de autenticação nas rotas protegidas
    mux.Handle("/api/", http.StripPrefix("/api", auth.Middleware(rotasProtegidas)))

    log.Println("Servidor rodando em http://localhost:8080")
    log.Println("Rotas disponíveis:")
    log.Println("  POST /login          - Autenticação")
    log.Println("  POST /refresh        - Renovar token")
    log.Println("  GET  /api/perfil     - Perfil (protegida)")
    log.Println("  GET  /api/dashboard  - Dashboard (protegida)")

    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal("Erro ao iniciar servidor:", err)
    }
}

Testando a API

# 1. Definir o secret (obrigatório!)
export JWT_SECRET="minha-chave-secreta-super-segura-com-32-chars"

# 2. Iniciar o servidor
go run main.go

# 3. Fazer login
curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"joao@email.com","senha":"senha123"}'

# Resposta:
# {
#   "access_token": "eyJhbGciOiJIUzI1...",
#   "refresh_token": "eyJhbGciOiJIUzI1...",
#   "token_type": "Bearer",
#   "expires_in": 900
# }

# 4. Acessar rota protegida
curl http://localhost:8080/api/perfil \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1..."

# 5. Renovar token
curl -X POST http://localhost:8080/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"eyJhbGciOiJIUzI1..."}'

# 6. Testar sem token (deve retornar 401)
curl http://localhost:8080/api/perfil
# {"erro":"Token de autenticação não fornecido","codigo":401}

Estratégia de Refresh Token

A ideia do refresh token é manter o usuário logado sem expor o access token por muito tempo:

┌──────────┐                    ┌──────────┐
│  Cliente  │                    │ Servidor │
└─────┬────┘                    └────┬─────┘
      │                              │
      │ POST /login (email+senha)    │
      │─────────────────────────────>│
      │                              │
      │ access_token (15min)         │
      │ refresh_token (7 dias)       │
      │<─────────────────────────────│
      │                              │
      │ GET /api/dados               │
      │ Authorization: Bearer <AT>   │
      │─────────────────────────────>│
      │      200 OK                  │
      │<─────────────────────────────│
      │                              │
      │  ... 15 minutos depois ...   │
      │                              │
      │ GET /api/dados               │
      │ Authorization: Bearer <AT>   │
      │─────────────────────────────>│
      │      401 Token expirado      │
      │<─────────────────────────────│
      │                              │
      │ POST /refresh (refresh_token)│
      │─────────────────────────────>│
      │  Novo access_token (15min)   │
      │<─────────────────────────────│
      │                              │

Boas Práticas de Segurança

1. Use Secrets Fortes

# Gere uma chave aleatória com OpenSSL
openssl rand -base64 32
# Resultado: K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=

2. Nunca Armazene Tokens no localStorage

No frontend, prefira cookies HttpOnly:

// Definir token como cookie HttpOnly (mais seguro que localStorage)
http.SetCookie(w, &http.Cookie{
    Name:     "access_token",
    Value:    accessToken,
    HttpOnly: true,   // JavaScript não pode acessar
    Secure:   true,   // Apenas HTTPS
    SameSite: http.SameSiteStrictMode,
    Path:     "/",
    MaxAge:   900,    // 15 minutos
})

3. Valide Sempre o Algoritmo

// SEMPRE verifique o algoritmo no callback de validação
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
    // Isso previne ataques "alg: none"
    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, fmt.Errorf("algoritmo inesperado: %v", token.Header["alg"])
    }
    return secret, nil
})

4. Não Coloque Dados Sensíveis no Payload

O payload do JWT e codificado em Base64, nao e criptografado. Qualquer pessoa pode decodificar e ler o conteudo. Nunca inclua senhas, dados de cartao de credito ou informacoes confidenciais.

5. Implemente Blacklist para Logout

// Em produção, use Redis para armazenar tokens revogados
var tokensRevogados = make(map[string]bool)

func Logout(w http.ResponseWriter, r *http.Request) {
    // Extrai o token do header
    tokenString := extrairToken(r)

    // Adiciona à blacklist (em produção, use Redis com TTL)
    tokensRevogados[tokenString] = true

    json.NewEncoder(w).Encode(map[string]string{
        "mensagem": "Logout realizado com sucesso",
    })
}

// No middleware, verifique a blacklist antes de validar
func estaRevogado(token string) bool {
    return tokensRevogados[token]
}

Armadilhas Comuns

  1. Usar HS256 com chave fraca: use pelo menos 32 bytes aleatórios
  2. Não verificar o algoritmo: permite ataques de troca de algoritmo
  3. Tokens sem expiração: sempre defina exp e mantenha curto (15-30 min)
  4. Armazenar dados sensíveis no payload: o JWT nao e criptografado
  5. Ignorar HTTPS: tokens podem ser interceptados em conexoes HTTP
  6. Hardcoded secrets no codigo: use variaveis de ambiente ou vault

Conclusão

JWT com Go é uma combinação eficiente e segura para autenticação de APIs. Os pontos essenciais:

  • Use a biblioteca golang-jwt/jwt/v5 (mantida e atualizada)
  • Access tokens curtos (15 min) + refresh tokens longos (7 dias)
  • Sempre valide o algoritmo no callback de parse
  • Middleware centraliza a lógica de autenticação
  • Nunca armazene dados sensíveis no payload do JWT
  • Use HTTPS em produção e cookies HttpOnly quando possível
  • Implemente blacklist para suportar logout

Com esse sistema, sua API Go está pronta para autenticação em produção. O próximo passo é integrar com um banco de dados real e adicionar funcionalidades como controle de permissões (RBAC) e autenticação multi-fator (MFA).