Управление сессией через Fastify-cookie

Fastify-cookie обеспечивает базовый механизм работы с cookies в приложениях на Fastify, но сам по себе не решает задачу безопасного хранения сессионных данных. Для построения защищённой сессии используется связка cookie + шифрование/подпись через библиотеку Iron (@hapi/iron), которая позволяет «запечатывать» объект состояния в строку и безопасно восстанавливать его на сервере.

Для начала подключается набор зависимостей:

npm install fastify @fastify/cookie @hapi/iron

Fastify-cookie регистрируется как плагин, после чего становится доступен механизм чтения и записи cookies в каждом запросе.

import Fastify from 'fastify'
import cookie from '@fastify/cookie'

const app = Fastify()

app.register(cookie, {
  secret: 'super-secret-key-base'
})

Параметр secret используется для подписи cookies, что предотвращает их незаметную подмену на стороне клиента.

Cookie в классической реализации Fastify представляет собой хранилище небольшого объёма данных на клиенте. Однако хранить там напрямую состояние пользователя небезопасно:

  • данные могут быть прочитаны
  • данные могут быть изменены
  • размер ограничен браузером

Поэтому используется схема:

  1. Сервер формирует объект сессии
  2. Объект шифруется через Iron
  3. Результат сохраняется в cookie
  4. При каждом запросе cookie расшифровывается
  5. Сервер восстанавливает исходное состояние

Основы работы с @hapi/iron

Iron реализует механизм «sealing/unsealing» — упаковки и распаковки данных с криптографической защитой.

Упаковка данных

import Iron from '@hapi/iron'

const password = 'complex-32-characters-minimum-secret'

const session = {
  userId: 42,
  role: 'admin'
}

const sealed = await Iron.seal(session, password, Iron.defaults.encryption)

Результатом является строка, которую безопасно хранить в cookie.

Распаковка данных

const unsealed = await Iron.unseal(sealed, password, Iron.defaults.encryption)

Если строка была изменена клиентом, расшифровка завершится ошибкой.

Интеграция Iron с Fastify-cookie строится вручную через middleware-логику.

Создание сессии

app.post('/login', async (req, reply) => {
  const user = {
    id: 1,
    username: 'admin'
  }

  const sessionData = await Iron.seal(user, 'complex-32-characters-minimum-secret', Iron.defaults.encryption)

  reply.setCookie('session', sessionData, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/',
    maxAge: 60 * 60 * 24
  })

  return { ok: true }
})

Чтение сессии

app.get('/profile', async (req, reply) => {
  const sessionCookie = req.cookies.session

  if (!sessionCookie) {
    return reply.code(401).send({ error: 'unauthorized' })
  }

  const session = await Iron.unseal(
    sessionCookie,
    'complex-32-characters-minimum-secret',
    Iron.defaults.encryption
  )

  return {
    userId: session.id,
    username: session.username
  }
})

При работе с сессиями критически важна конфигурация cookie:

  • httpOnly — блокирует доступ из JavaScript
  • secure — передача только по HTTPS
  • sameSite — защита от CSRF
  • maxAge — время жизни сессии
reply.setCookie('session', value, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/',
  maxAge: 3600
})

Обновление сессии

При необходимости можно перезаписывать cookie, обновляя данные пользователя:

app.post('/refresh-session', async (req, reply) => {
  const sessionCookie = req.cookies.session

  const session = await Iron.unseal(
    sessionCookie,
    'complex-32-characters-minimum-secret',
    Iron.defaults.encryption
  )

  session.lastActivity = Date.now()

  const renewed = await Iron.seal(
    session,
    'complex-32-characters-minimum-secret',
    Iron.defaults.encryption
  )

  reply.setCookie('session', renewed, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  })

  return { refreshed: true }
})

Выход из системы

Удаление сессии выполняется через очистку cookie:

app.post('/logout', async (req, reply) => {
  reply.clearCookie('session', {
    path: '/'
  })

  return { loggedOut: true }
})

Защита от модификации данных

Основное преимущество Iron заключается в том, что даже при доступе к cookie клиент не может изменить данные незаметно. Любая модификация строки нарушает криптографическую подпись и делает распаковку невозможной.

Это позволяет хранить в сессии:

  • идентификаторы пользователей
  • роли доступа
  • временные метки
  • служебное состояние запроса

без риска подделки.

Разделение ответственности

В архитектуре Fastify-cookie + Iron:

  • cookie отвечает за транспортировку данных между клиентом и сервером
  • Iron отвечает за целостность и конфиденциальность
  • Fastify отвечает за маршрутизацию и обработку запросов

Такое разделение упрощает масштабирование логики сессий без введения серверного хранилища.

Ограничения подхода

Использование зашифрованных cookie-сессий накладывает ряд ограничений:

  • размер данных ограничен (~4 KB на cookie)
  • невозможность мгновенного принудительного отзыва сессии без дополнительного механизма
  • нагрузка на CPU при частом шифровании/дешифровании
  • необходимость строгого контроля секретного ключа

Для более сложных систем часто комбинируют этот подход с серверным хранилищем токенов или Redis-сессиями.

Типовая структура middleware для работы с сессией

app.addHook('preHandler', async (req, reply) => {
  const cookie = req.cookies.session
  if (!cookie) {
    req.session = null
    return
  }

  try {
    req.session = await Iron.unseal(
      cookie,
      'complex-32-characters-minimum-secret',
      Iron.defaults.encryption
    )
  } catch {
    req.session = null
  }
})

После этого обработчики получают доступ к req.session без повторного декодирования.

Практика организации секретов

Ключевой момент — длина и хранение секретного ключа:

  • минимум 32 символа
  • хранение в переменных окружения
  • запрет на хардкод в коде
const PASSWORD = process.env.SESSION_SECRET

Ошибки в управлении ключом приводят к полной компрометации всей системы сессий, поскольку старые cookie становятся недействительными при смене секрета.

Поведение при повреждённой сессии

При невозможности расшифровать cookie типичный сценарий:

  • удаление cookie
  • принудительный выход пользователя
  • генерация новой сессии при следующем входе
try {
  const session = await Iron.unseal(...)
} catch (err) {
  reply.clearCookie('session')
  return reply.code(401).send()
}

Совместимость с масштабируемыми системами

Подход с Fastify-cookie и Iron хорошо подходит для:

  • монолитных приложений
  • API без серверного состояния
  • микросервисов с JWT-альтернативами на уровне cookie

Но становится менее удобным при:

  • необходимости мгновенного logout на всех устройствах
  • распределённых системах с централизованной блокировкой сессий
  • сложных политик безопасности с ревокацией токенов