API Keys em Node.js: autenticação de serviços

API Keys são o padrão para autenticação de serviços machine-to-machine, integrações B2B, SDKs de terceiros, webhooks e chamadas server-to-server. Diferente do JWT, a API key é um segredo opaco que o servidor valida comparando com o hash armazenado no banco.

Gerar e armazenar API Keys com segurança

Nunca armazenar a chave em texto puro, apenas o hash SHA-256:

typescript
// lib/api-keys.ts
import crypto from 'crypto'
import { db } from './db'

// Formato: rsk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
// Prefixo identificável + ambiente + chave aleatória
export function gerarApiKey(ambiente: 'live' | 'test' = 'live'): string {
  const randomBytes = crypto.randomBytes(24).toString('base64url')
  return `rsk_${ambiente}_${randomBytes}`
}

// Hash SHA-256 para armazenar no banco (nunca a chave em si):
export function hashApiKey(chave: string): string {
  return crypto.createHash('sha256').update(chave).digest('hex')
}

// Criar nova API Key para um usuário/organização:
export async function criarApiKey(params: {
  usuarioId: string
  nome: string       // "Integração ERP", "Webhook Shopify"
  permissoes: string[]
  expiresAt?: Date
}) {
  const chave = gerarApiKey()
  const hash = hashApiKey(chave)

  const { rows: [apiKey] } = await db.query(
    `INSERT INTO api_keys (usuario_id, nome, hash, permissoes, expires_at)
     VALUES ($1, $2, $3, $4, $5)
     RETURNING id, nome, criado_em`,
    [params.usuarioId, params.nome, hash, params.permissoes, params.expiresAt ?? null]
  )

  // Retornar a chave completa APENAS neste momento — nunca mais será exibida:
  return { ...apiKey, chave }
}

// Validar API Key recebida na requisição:
export async function validarApiKey(chave: string) {
  const hash = hashApiKey(chave)

  const { rows: [apiKey] } = await db.query(
    `SELECT ak.*, u.id AS usuario_id, u.email
     FROM api_keys ak
     JOIN usuarios u ON u.id = ak.usuario_id
     WHERE ak.hash = $1
       AND ak.ativo = true
       AND (ak.expires_at IS NULL OR ak.expires_at > NOW())`,
    [hash]
  )

  if (!apiKey) return null

  // Atualizar last_used_at:
  await db.query('UPDATE api_keys SET last_used_at = NOW() WHERE id = $1', [apiKey.id])

  return apiKey
}

Middleware de autenticação por API Key

Aceitar chave no header X-API-Key ou Authorization Bearer:

typescript
// middleware/api-key-auth.ts
import { validarApiKey } from '../lib/api-keys'

export async function autenticarApiKey(req: Request, res: Response, next: NextFunction) {
  // Aceitar nos dois formatos comuns:
  const chave =
    req.headers['x-api-key'] as string ||
    (req.headers.authorization?.startsWith('Bearer ')
      ? req.headers.authorization.slice(7)
      : null)

  if (!chave) {
    return res.status(401).json({ error: 'API Key não fornecida' })
  }

  // Validar formato antes de consultar o banco:
  if (!/^rsk_(live|test)_[A-Za-z0-9_-]{32}$/.test(chave)) {
    return res.status(401).json({ error: 'Formato de API Key inválido' })
  }

  const apiKey = await validarApiKey(chave)
  if (!apiKey) {
    return res.status(401).json({ error: 'API Key inválida ou expirada' })
  }

  // Adicionar contexto ao request:
  req.apiKey = apiKey
  req.usuario = { id: apiKey.usuario_id, email: apiKey.email }
  next()
}

// Verificar permissão específica:
export function exigirPermissao(permissao: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.apiKey?.permissoes?.includes(permissao)) {
      return res.status(403).json({
        error: 'Permissão negada',
        required: permissao,
        available: req.apiKey?.permissoes,
      })
    }
    next()
  }
}

// Usar nas rotas:
app.post('/api/v1/pedidos',
  autenticarApiKey,
  exigirPermissao('pedidos:write'),
  criarPedidoHandler
)

Cache de validação no Redis

Evitar query ao banco em cada requisição com cache de 5 minutos:

typescript
// lib/api-keys.ts — adicionar cache Redis:
import { redis } from './redis'

const CACHE_TTL = 300  // 5 minutos

export async function validarApiKey(chave: string) {
  const hash = hashApiKey(chave)
  const cacheKey = `api_key:${hash}`

  // Verificar cache primeiro:
  const cached = await redis.get(cacheKey)
  if (cached === 'invalid') return null  // chave inválida conhecida
  if (cached) return JSON.parse(cached)  // chave válida em cache

  // Consultar banco:
  const { rows: [apiKey] } = await db.query(
    `SELECT ak.id, ak.permissoes, ak.expires_at, ak.ativo,
            u.id AS usuario_id, u.email
     FROM api_keys ak JOIN usuarios u ON u.id = ak.usuario_id
     WHERE ak.hash = $1 AND ak.ativo = true
       AND (ak.expires_at IS NULL OR ak.expires_at > NOW())`,
    [hash]
  )

  if (!apiKey) {
    // Cachear resultado negativo (evitar brute force de banco):
    await redis.setex(cacheKey, 60, 'invalid')  // TTL menor para inválidas
    return null
  }

  // Cachear resultado positivo:
  await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(apiKey))

  // Atualizar last_used (fire-and-forget, não bloquear a resposta):
  db.query('UPDATE api_keys SET last_used_at = NOW() WHERE id = $1', [apiKey.id]).catch(() => {})

  return apiKey
}

// Ao revogar uma chave: limpar o cache imediatamente:
export async function revogarApiKey(id: string) {
  const { rows: [key] } = await db.query(
    'UPDATE api_keys SET ativo = false WHERE id = $1 RETURNING hash',
    [id]
  )
  if (key) await redis.del(`api_key:${key.hash}`)
}

Dashboard de gerenciamento de API Keys

Endpoints para criar, listar e revogar chaves via API:

typescript
// Listar API Keys do usuário (sem mostrar o hash):
app.get('/api/api-keys', autenticar, async (req, res) => {
  const { rows } = await db.query(
    `SELECT id, nome, permissoes, criado_em, last_used_at,
            expires_at, ativo,
            -- Mostrar apenas prefixo para identificação:
            'rsk_****' AS chave_preview
     FROM api_keys
     WHERE usuario_id = $1
     ORDER BY criado_em DESC`,
    [req.usuario!.userId]
  )
  res.json(rows)
})

// Criar nova API Key:
app.post('/api/api-keys', autenticar, async (req, res) => {
  const { nome, permissoes, expiresIn } = req.body as {
    nome: string
    permissoes: string[]
    expiresIn?: number  // dias
  }

  const expiresAt = expiresIn
    ? new Date(Date.now() + expiresIn * 86_400_000)
    : undefined

  const apiKey = await criarApiKey({
    usuarioId: req.usuario!.userId,
    nome,
    permissoes,
    expiresAt,
  })

  // chave é retornada APENAS aqui — armazenar do lado do cliente
  res.status(201).json({
    ...apiKey,
    aviso: 'Salve a chave agora — ela não será exibida novamente',
  })
})

// Revogar:
app.delete('/api/api-keys/:id', autenticar, async (req, res) => {
  await revogarApiKey(req.params.id)
  res.json({ message: 'API Key revogada' })
})

Rate limiting por API Key

Limitar requisições por chave usando Redis:

typescript
// middleware/rate-limit-api-key.ts
import { redis } from '../lib/redis'

interface RateLimitConfig {
  windowMs: number    // janela em ms
  max: number         // max requisições por janela
}

export function rateLimitPorApiKey(config: RateLimitConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const keyId = req.apiKey?.id
    if (!keyId) return next()

    const window = Math.floor(Date.now() / config.windowMs)
    const redisKey = `rl:apikey:${keyId}:${window}`

    const count = await redis.incr(redisKey)
    if (count === 1) {
      // Definir expiração na primeira requisição da janela:
      await redis.pexpire(redisKey, config.windowMs)
    }

    // Adicionar headers informativos:
    res.setHeader('X-RateLimit-Limit', config.max)
    res.setHeader('X-RateLimit-Remaining', Math.max(0, config.max - count))
    res.setHeader('X-RateLimit-Reset', Math.ceil((window + 1) * config.windowMs / 1000))

    if (count > config.max) {
      return res.status(429).json({
        error: 'Rate limit excedido',
        retryAfter: Math.ceil(config.windowMs / 1000),
      })
    }

    next()
  }
}

// Usar nas rotas:
app.use('/api/v1',
  autenticarApiKey,
  rateLimitPorApiKey({ windowMs: 60_000, max: 100 })  // 100 req/min
)

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Se o banco de dados vazar (SQL injection, dump acidental), o atacante obtém apenas os hashes SHA-256, computacionalmente inviável de reverter para a chave original. Stripe, GitHub e Twilio seguem esse padrão: a chave é exibida uma única vez na criação e nunca mais recuperável. O hash é determinístico: hashApiKey(chave) sempre produz o mesmo hash, permitindo validação sem armazenar a chave.

API Key: mais simples, sem expiração automática (ou com expiração longa), revogação imediata, fácil de implementar em qualquer linguagem com um header HTTP. JWT: stateless (sem consulta ao banco na verificação), carrega claims no payload, expira automaticamente. Para M2M (machine-to-machine) com poucos clientes conhecidos: API Key. Para microserviços que precisam escalar sem consulta ao banco: JWT.

Suporte a múltiplas chaves ativas simultaneamente por tenant: o cliente cria a nova chave, configura nos sistemas dele, testa, e só então revoga a antiga. Período de sobreposição de 7-30 dias. Facilite a rotação no dashboard: botão "Criar nova chave" que não revoga a anterior automaticamente. Envie email de aviso para chaves próximas do vencimento (30, 7, 1 dia antes).

Sim, é uma boa prática usada por Stripe (sk_live_, sk_test_), GitHub (ghp_), Twilio (AC...). Vantagens: (1) fácil identificar a origem em logs e incident response; (2) scanners de secrets (Gitleaks, GitHub secret scanning) reconhecem o padrão e alertam se a chave vazar em um repositório; (3) o prefix diferencia chaves de outros serviços. Configure padrão regex no Gitleaks para o seu prefix.

Registre em log: timestamp, api_key_id (nunca a chave), endpoint, método HTTP, status da resposta, IP de origem, user-agent e latência. Armazene em tabela api_key_logs ou envie para o Elasticsearch/Loki. Permita que o usuário veja o histórico de uso no dashboard (last_used_at, total de requisições, endpoints mais usados). Alerte para padrões anômalos: volume incomum, IPs novos, endpoints nunca usados.