Лицензия как JWT: архитектура и реализация для Spring Boot
опубликовано: ⏰ 15Задача добавления лицензий в продукты не нова и часто встречается. В статье хотел привести пример одного из вариантов реализации для Java-приложений – от выбора формата лицензии до декларативной защиты бизнес-методов. Подход относительно простой, без использования защищённых хранилищ, цепочек серверов лицензий и прочего.
На каждом этапе покажу альтернативы и объясню, почему выбрал тот или иной вариант. В качестве небольших отсылок и практических примеров будут проекты tekBlueprint.authz (пока внутренний закрытый проект с общими подключаемыми компонентами и SPI) и tekBlueprint.infra.
Проблема и задачи
Коммерческое приложение нужно защитить от несанкционированного использования. Требуется:
- Контроль доступа к премиум-функциям по фичам (
feature flag). - Ограничение количества сущностей – проектов, пользователей, сервисов (
limit). - Проверка срока действия лицензии.
- Возможность работы без постоянного подключения к серверу лицензий.
- Защита лицензии от подделки и несанкционированного чтения.
Поэтому требуется спроектировать систему, которая:
- Генерирует криптографически защищённые лицензии с данными о плане, фичах и лимитах.
- Позволяет приложению проверять лицензию при старте (офлайн или онлайн).
- Даёт разработчикам простой механизм защиты методов – без встраивания проверок в бизнес-логику.
- Обеспечивает graceful degradation – приложение запускается даже без лицензии, но с ограничениями.
Выбор формата лицензии
Навскидку есть пять очевидных подходов к хранению и передаче данных лицензии:
- Проприетарный бинарный формат – собственная структура данных, сериализованная в байты. Требует написания парсера с обеих сторон, нет стандартных инструментов для отладки, высокий TCO на поддержку.
- XML + XML-DSig / XML-Encryption – стандарт W3C, криптографически корректен. Раньше в основном этот подход использовал, вполне рабочий надёжный. Но XML тяжёлый по объёму, парсинг медленный, а в экосистеме Spring Boot нет встроенной поддержки – придётся писать интеграцию с нуля.
- JSON + HMAC/SHA256 – простой подход, подписываем JSON хешем. Проблема – данные не зашифрованы, и HMAC требует обмена секретным ключом, что менее удобно для публичной верификации. Кроме того, HMAC не даёт конфиденциальности – любой, кто имеет токен, видит все данные.
- X.509-сертификаты с расширениями – распространённый и надёжный стандарт, но слишком жёсткая схема: сложно добавить произвольные лимиты и фичи без модификации структуры сертификата. Неудобно для динамических формируемых данных.
- JOSE (JWT / JWS / JWE) – стандарт IETF, нативная поддержка в Java через библиотеку nimbus-jose-jwt, компактный формат, встроенные алгоритмы подписи и шифрования.
Подходы формирования ключа
Забегая вперёд, скажу что понадобятся два режима проверки лицензии offline, online. То есть это использование JWS и JWE.
В случае JWE данные можно подписывать и шифровать двумя способами: Sign-then-Encrypt и Encrypt-then-Sign. RFC 8725 §3 рекомендует Encrypt-then-Sign как предпочтительный вариант, поскольку подпись охватывает зашифрованные данные и гарантирует их целостность до расшифровки. Однако для лицензий часто выбирают Sign-then-Encrypt, потому что:
| Критерий | Sign-then-Encrypt | Encrypt-then-Sign |
|---|---|---|
| Конфиденциальность подписи | Подпись скрыта внутри зашифрованного токена – злоумышленник не может её проанализировать | Подпись видна в открытом виде |
| Порядок операций | Сначала подписываем оригинальные данные, потом шифруем весь пакет | Сначала шифруем, потом подписываем ciphertext – получатель проверяет подпись до расшифровки |
| Практика для лицензий | Удобнее: подпись гарантирует целостность исходных данных, а шифрование добавляет конфиденциальность | Более строгий криптографический подход, но сложнее в реализации и отладке |
Оба варианта криптографически корректны. Выбор зависит от требований к конфиденциальности подписи и удобства верификации на стороне клиента.
Полный цикл формирования ключа выглядит так:
Claims (JSON) → JWS подпись → JWE шифрованиеВажно помнить, что в схеме с JWE, чтобы клиент мог проверить и прочитать такую лицензию offline, вам придется встроить ключи прямо в код приложения, то есть:
- Для проверки подписи. В приложение встраивается публичный ключ сервера. Это безопасно.
- Для дешифрования. В приложение придется встроить приватный ключ дешифрования. Это главный риск. Если злоумышленник декомпилирует ваше приложение, он достанет этот приватный ключ. Если приватный ключ дешифрования будет украден, можно расшифровать данные чужих лицензий. Но все еще нельзя создавать свои лицензии, так как нет приватного ключа подписи сервера.
В моём случае в лицензии никаких конфиденциальных данных нет и для офлайн-режима может использоваться только JWS без шифрования – лицензия читаема, но защищёна от подделки подписью. Это компромисс между безопасностью и удобством: данные лицензии (план, фичи) не являются секретными, а подпись гарантирует целостность.
Структура данных лицензии
Это обычный JSON и можете формировать как удобно. Например, лицензия содержит два уровня данных – стандартные JWT-claims и ваш вложенный объект metadata:
{
"jti": "lic-8f3a9c1b-4d2e",
"iss": "https://lic.tekblueprint.org",
"sub": "acme-corp",
"iat": 1750000000,
"exp": 1781536000,
"metadata": {
"plan": "enterprise",
"region": "eu-west-1",
"features": [
"ai.assistants",
"automation.hw-plan"
],
"limits": {
"software.projects-max": 50,
"software.systems-max": -1
}
}
}Почему вложенная структура metadata, а не плоский JSON:
- Стандартные claims (
jti,iss,sub,iat,exp) имеют чёткое назначение по RFC 7519 – их парсер ожидает на верхнем уровне - Операционные данные (план, фичи, лимиты) изолированы в отдельном объекте – это упрощает расширение без конфликтов с будущими стандартами JWT
- В коде это отражено двумя классами:
LicenseClaimsдля стандартных полей иLicenseMetadataдля бизнес-данных
В качестве альтернативы можно было хранить фичи и лимиты как отдельные атрибуты на верхнем уровне:
{
"jti": "...",
"exp": ...,
"plan": "enterprise",
"features": [
...
],
"limits": {
...
}
}Проблема такого подхода, что при добавлении новых полей (например, quotas, addons) они будут «разбросаны» по верхнему уровню. Вложенный metadata решает эту проблему: любые новые поля добавляются внутрь одного объекта.
Генерация лицензий
Если есть время и желание, то можно написать веб-сервис с UI для управления лицензиями. Это удобно и доступно для пользователей.
Но есть и более быстрая альтернатива в виде CLI-инструмента для администратора. При этом и в этом варианте может быть отдельный сервер лицензий, к которому CLI подключается по gRPC/REST.
Пример возможного использования CLI ниже. Несколько громоздко, но всё это автоматизируется созданием дополнительных скриптов, использованием CSV с созданием лицензий батчами и так далее.
authz license create \
--signing-key signing-private.pem \
-t acme-corp \
--plan enterprise \
-F ai.assistants,automation.hw-plan \
-L software.projects-max=50,software.systems-max=-1 \
-d 365 \
--no-encryptЕсли свести всё вместе:
- Встроенная генерация в приложении – приватный ключ хранится внутри приложения. Это странное решение и так делать никогда не нужно. Если злоумышленник получит доступ к серверу, он сможет генерировать произвольные лицензии.
- Отдельный сервис лицензирования (License Server) – как писал выше, генерация и хранение лицензий на выделенном сервере с базой данных. Подходит для онлайн-режима, где приложение периодически обращается к серверу для проверки статуса. Сложнее в развёртывании, но даёт контроль над отзывом лицензий.
- CLI-генератор – ключи хранятся у администратора, лицензии генерируются локально и передаются клиенту. Минимальная инфраструктура, хорошо подходит для офлайн-лицензий.
Алгоритмы подписи
Здесь нужно выбирать с точки зрения разумности и достаточности. Как пример:
| Алгоритм | Ключ | Скорость |
|---|---|---|
| ES256 (ECDSA P-256) | 256 бит | Быстрее, меньше лицензия |
| RS256 (RSA) | 2048+ бит | Медленнее, больше лицензия |
ES256 предпочтителен: при той же криптостойкости подпись в 4 раза меньше, что важно для лицензий, передаваемых по сети или хранимых в переменных окружения.
Режимы верификации
В зависимости от условий использования и развёртывания можно поддержать несколько режимов верификации лицензий.
Офлайн-режим (JWS)
Если приложение развёрнуто в изолированной среде без доступа к внешним сервисам, то требуется проверить лицензию при старте, используя только локальные ресурсы. Для этого лицензия формируется как JWS и подписывается приватным ключом. В приложение встроен публичный ключ для проверки подписи:

Преимущества:
- Нет зависимости от сети при старте.
- Проверка занимает миллисекунды.
- Публичный ключ – не секрет, его можно свободно распространять вместе с приложением.
Недостатки:
- Невозможно отозвать лицензию дистанционно – лицензия действителена до
exp. - Данные лицензии читаемы (base64-decode payload) – подходит, если план и фичи не являются секретом.
Онлайн-режим (JWE + серверная проверка)
Если требуется контроль над лицензиями в реальном времени: возможность отзыва, аудит использования, защита данных лицензии от чтения. То лицензия формируется уже как JWE. Приложение расшифровывает лицензию своим приватным ключом и отправляет запрос на сервер лицензий для проверки статуса:

Преимущества:
- Данные лицензии зашифрованы – без ключа расшифровки содержимое нечитаемо.
- Сервер может отозвать лицензию, изменив статус в базе данных.
- Хеш лицензии на сервере гарантирует, что лицензия не была модифицирована.
Недостатки:
- Требуется сетевое подключение при старте.
- Дополнительная инфраструктура – сервер лицензий с базой данных.
- Сложнее развёртывание: нужно управлять парами ключей шифрования на обеих сторонах.
Гибридный вариант: сервер как CA
Главная проблема классического онлайн-режима – приватный ключ дешифрования хранится в приложении. Если приложение всё равно обращается к серверу лицензий, локальная расшифровка избыточна. Ключ остаётся на сервере:

Сервер выступает как CA: он расшифровывает оригинальный токен и выдаёт короткоживущий JWS-билет с данными лицензии, подписанный публичным ключом сервера. Приложение проверяет подпись билета офлайн, но данные валидны только ограниченное время (например, 24 часа). Приватный ключ дешифрования не покидает сервер, а приложение работает автономно между обращениями. Если сервер лицензий временно недоступен, то приложение продолжает работать по старому билету до истечения TTL.
Преимущества:
- Приватные ключи (подписи и шифрования) живут только на сервере лицензий. В приложении – ноль секретов.
- Сервер контролирует полный цикл: расшифровка → проверка подписи → проверка отзыва в БД.
- Токен передаётся по HTTPS – перехват даёт зашифрованный пакет, бесполезный без ключа.
Недостатки:
- Приложение полностью зависит от сервера лицензий. Если сервер недоступен при старте – приложение не запустится (нужна стратегия retry и fallback).
- Каждый старт = сетевой запрос. Для кластера из сотен инстансов это нагрузка на сервер лицензий.
- Сервер возвращает расшифрованные данные лицензии в ответе – нужно защищать endpoint (mTLS, rate limit).
Сравнение режимов
| Критерий | Офлайн (JWS) | Онлайн (JWE) |
|---|---|---|
| Конфиденциальность данных | Нет (payload читаем) | Да |
| Отзыв лицензии | Только по истечении exp | Мгновенно через сервер |
| Зависимость от сети | Нет | Да, при старте |
| Сложность развёртывания | Минимальная | Требует сервер лицензий |
| Применимость | Для простых лицензий | Для расширенных корпоративных лицензий |
Все три подхода можно комбинировать в зависимости от требований заказчика и ограничений инфраструктуры. Например: офлайн-режим для изолированных сред, онлайн или гибридный – когда требуется контроль отзыва лицензий.
Проверка лицензии при старте приложения
Проблема: что делать, если лицензия отсутствует? Приложение не должно падать – оно должно по уровням ограничивать доступный функционал.
Как пример решения, это многоуровневая цепочка поиска лицензии:
1. LICENSE_TOKEN (переменная окружения)
↓ нет лицензии
2. LicenseTokenProvider (кастомный бин Spring)
↓ нет лицензии
3. Bundled free license (например, встроенная бесплатная лицензия в classpath)
↓ нет лицензии
4. Grace period (синтетическая лицензия на 72 часа, до полной блокировки)Каждый уровень – это отдельный источник лицензии. Если текущий уровень не вернул лицензию, система переходит к следующему. Это обеспечивает максимальную гибкость развёртывания:
- CI/CD pipeline может передать лицензию через переменную окружения.
- Docker-контейнер может примонтировать файл с лицензией и прочитать его через
LicenseTokenProvider. - Дистрибутив может содержать встроенную бесплатную лицензию для ознакомительного периода.
Интерфейс LicenseTokenProvider может понадобиться, если на стороне приложения требуется какой-то нестандартный источник хранения лицензий:
@Component
@ConditionalOnProperty(name = "tb-authz.license.file")
public class LicenseTokenProviderImpl implements LicenseTokenProvider {
@Value("${tb-authz.license.file}")
private Path file;
@Override
public String provide() {
if (file == null || !Files.exists(file)) {
return null; // Переход к следующему уровню fallback
}
try {
log.info("Loading license token from {}", file);
return Files.readString(file).trim();
} catch (Exception e) {
log.error("Cannot read license token from {}", file, e);
return null;
}
}
}Почему @ConditionalOnProperty, а не обычный @Component: провайдер активируется только при наличии свойства tb-authz.license.file. Если свойство не задано – бин не создаётся, и система автоматически переходит к следующему уровню fallback без ошибок конфигурации.
После успешной верификации лицензии её данные лучше кэшировать в памяти. Это необходимо для обеспечения достаточной производительности, так как лицензия проверяется один раз при старте, а не при каждом вызове защищённого метода.
Защита бизнес-методов
Наивный подход – встраивать проверки лицензии прямо в код методов:
// Плохо: проверка смешана с бизнес-логикой
public Project create(ProjectNewRecord record) {
if (!license.hasFeature("projects")) {
throw new LicenseException("Projects not licensed");
}
if (projectsRepository.count() >= license.getLimitValue("software.projects-max")) {
throw new LicenseLimitExceededException(...);
}
// ... бизнес-логика создания проекта
}Проблемы такого подхода:
- Бизнес-логика загромождена проверками лицензий.
- Легко забыть добавить проверку в новый метод.
- Трудно поддерживать – изменения политики лицензирования требуют правки множества файлов.
- Невозможно централизованно логировать проверки.
Решение: AOP + аннотации
Использование аспектно-ориентированного программирования (AOP) позволяет отделить проверку лицензии от бизнес-логики. Разработчик просто ставит аннотацию над методом, а проверка выполняется автоматически через интерцептор.
Этот подход показался наиболее удобным с использованием SpEL. По сути это аналог @PreAuthorize из Spring Security:
@PreAuthorize("hasPermission('infra-ai-assistant', 'use') and hasPermission('infra-software-system', 'create')")Небольшая сравнительная таблица с подходами проверки лицензий в бизнес логике:
| Подход | Плюсы | Минусы |
|---|---|---|
| Аспекты (AOP) | Декларативно, централизованно, не загромождает код | Требует понимания Spring AOP, сложнее отлаживать |
| Ручная проверка | Прозрачно, легко отладить | Дублирование кода, смешивание ответственностей |
| Фильтр / интерцептор на уровне HTTP | Работает до входа в сервисный слой | Не защищает внутренние вызовы между сервисами; привязан к веб-слою |
| Фабрики объектов с проверкой | Проверка при построении объекта | Аналог AOP, но требует больше ручной работы |
@RequiresFeature
Проверяет наличие фичи в лицензии:
@RequiresFeature("ai.assistants")
public SseEmitter generate(AssistantInfraChatQuery request) {
// Код выполняется только если лицензия содержит "ai.assistants"
}Аннотация поддерживает SpEL для динамического определения фичи:
// Фича определяется из параметра метода
@RequiresFeature("#ctx.featureName")
public void execute(Context ctx) { ...}
// Фича берётся из Spring-бина
@RequiresFeature("@configService.getRequiredFeature()")
public void process() { ...}Внутренняя работа аспекта:
- Interceptor перехватывает вызов метода через
@Aroundadvice. - Получает данные из лицензии.
- Проверяет, что лицензия не истекла.
- Разрешает SpEL-выражение (если нужно) для получения имени фичи.
- Проверяет разрешение на использование фичи.
- Если проверка пройдена – вызывает оригинальный метод.
@RequiresLimit
Проверяет числовой лимит с динамическим вычислением текущего значения:
@RequiresLimit(
key = "software.projects-max",
value = "@projectsRepository.count() + 1"
)
public Project create(ProjectNewRecord record) { ...}Почему + 1 – выражение вычисляет значение после создания объекта. Если в базе уже 49 проектов, а лимит 50 – текущее значение будет 49 + 1 = 50, что не превышает лимит. При следующей попытке: 50 + 1 = 51 > 50 – исключение.
Сложные выражения:
@RequiresLimit(
key = "software.stream-redirects-max",
value = "#record.redirects != null ? @redirectRepo.countByProjectId(#record.projectId) + #record.redirects.size() : 0"
)
public StreamGenerator create(StreamNewRecord record) { ...}Здесь учитывается, что redirects может быть null, и суммируется текущее количество в базе с количеством новых записей из запроса.
Несколько лимитов на одном методе:
@RequiresLimit(key = "software.streams-max", value = "@streamRepo.countByProjectId(#record.projectId) + 1")
@RequiresLimit(key = "software.stream-redirects-max", value = "...")
public StreamGenerator create(StreamNewRecord record) { ...}Аннотации @Repeatable – можно ставить несколько аннотаций одного типа. Порядок проверки: сначала все @RequiresFeature (@Order(1)), затем все @RequiresLimit (@Order(2)). Это логично: если фича не лицензирована, нет смысла проверять лимиты.
SpEL в аннотациях
Чтобы не изобретать велосипед, используются уже рабочие выражения SpEL, которые имеют доступ к:
| Токен | Описание | Пример |
|---|---|---|
#paramName | Параметры метода | #record.projectId |
@beanName | Spring-бины по имени | @projectsRepository.count() |
| Литералы | Числа, строки, булевы значения | "50", true |
Можно было бы использовать простые литералы и передавать вычисленное значение через отдельный параметр. Но SpEL даёт гибкость: выражение описывает как вычислять текущее использование, а не требует от разработчика делать это вручную в коде метода.
Обработка ошибок
Почему лучше использовать непроверяемые (runtime) исключения, а не проверяемых (checked):
- Проверка лицензии – это инфраструктурная задача, которую разработчик бизнес-логики не должен обрабатывать явно через
try-catch. - Исключение должно “пробиться” до глобального обработчика, который преобразует его в HTTP-ответ.
- Проверяемое исключение заставило бы каждый метод добавлять
throws LicenseException– шум в сигнатурах.
Если используете Spring MVC, то хорошей практикой будет использование @RestControllerAdvice + @ExceptionHandler для автоматического приведения ошибок в понятный вид для клиента API.
Экспорт информации о лицензии на фронтенд
Фронтенду нужно знать, какие функции доступны пользователю, чтобы скрыть недоступные элементы UI до момента запроса к API. Без этой информации пользователь увидит кнопку «Создать проект», нажмёт её и получит ошибку 402/503.
Один из вариантов это передавать данные о лицензии через динамический runtime.js
Как альтернатива, можно сделать отдельный незащищённый ендпоит в API и отдавать данные через него.
Пример отображения информации о лицензии, без дополнительной локализации по фичам и лимитам.

Заключение
Лицензирование – это инфраструктурная задача с точки зрения приложения. Оно не должно проникать в бизнес-логику приложения. Декларативный подход через аннотации и AOP обеспечивает чистое разделение ответственностей: разработчик пишет бизнес-код, а система лицензий автоматически контролирует доступ.
При истечении лицензии хорошим подходом будет не полная блокировка работы, постепенная деградация доступных возможностей. Но в любом случае нельзя блокировать уже существующие данные пользователей, новые – можно.
И небольшая ремарка по выбору шифрования и организации ограничений в приложении. Сложность реализации защиты должна быть соразмерна ценности продукта. Цель лицензирования – не создать непреодолимый барьер (это невозможно), а повысить порог входа для несанкционированного использования. Дополнительные методы, которые можно применить:
- Шифрование лицензий в памяти усложняет чтение данных через отладчик, отсекая поверхностные попытки анализа.
- Встраивание проверок на уровне бинов требует знания внутренней архитектуры приложения для обхода.
- Вызовы к серверам лицензий можно защитить через mTLS и ротацию ключей, делая перехват трафика менее эффективным.
- Обфусцирование не является методом защиты само по себе, но в комбинации с другими мерами затрудняет реверс-инжиниринг.
- Сборка нативного приложения значительно усложняет анализ кода и отладку – достаточно для защиты от большинства атак.
Ни один метод не даёт абсолютной гарантии, но комплексный подход делает обход лицензии экономически нецелесообразным для подавляющего числа пользователей.