В каждом бэкенде наступает момент, когда рядом появляются id, uuid, slug, token, request_id и прочие похожие строки. Выглядят они одинаково, но отвечают за совершенно разные вещи:
curl https://api.example.com/users/123curl https://api.example.com/projects/550e8400-e29b-41d4-a716-446655440000curl https://example.com/articles/objectid-protiv-uuidcurl -X POST 'https://example.com/password/reset?token=...'
Ну да, тут id. Тут тоже id. Тут какой-то token, но он тоже строка. Тут slug, по нему же можно найти статью. Тут UUID, значит вроде безопасно. Тут JWT, значит пользователь авторизован.
А потом через полгода выясняется, что:
-
ссылку сброса пароля сделали через
user_id; -
UUIDв URL начинают воспринимать как защиту от доступа; -
slugначинают считать вечной айдишкой; -
API-ключ хранится в базе открытым текстом;
-
JWTживёт как вечная сессия; -
reset-tokenспокойно лежит в логах, APM и истории прокси.
Не потому что разработчики тупые. Обычно нет. Просто модель данных начала врать: одинаковый тип данных стали воспринимать как одинаковую ответственность.
Эта статья не про вечный спор “UUID или integer”. Это меньшая и довольно скучная часть проблемы. Настоящий вопрос другой: что именно вы сейчас держите в руках — идентификатор, публичную ссылку, секрет, токен доступа или человекочитаемый адрес?
Когда эти роли смешивают, получается архитектура, где UUID используют как безопасность, slug — как первичный ключ, reset-token — как user_id, а JWT — как вечную сессию. Система ещё работает, но уже начинает тихо врать сама себе, а значит создавать технический долг.
Сначала роль, потом формат
Самая частая ошибка начинается не с выбора UUID или integer.
Она начинается раньше — когда команда не отвечает на простой вопрос: что это значение должно делать?
Одна и та же строка может быть чем угодно:
123550e8400-e29b-41d4-a716-446655440000usr_01J2Y4K7YQ7V8K9W2V7R4D5H3Aid-token-uuid-slugpYp9cBW9wI8o4ZmU3uY1L2zYqR8b3S...req_01J2Y51A8ZY2J1X9V7E6D4C3B2
Но архитектурно это разные сущности.
У них разная стабильность, разный срок жизни, разная видимость снаружи, разные правила хранения, разная цена утечки, даже разные причины для изменения.
Поэтому вопрос “какой тип использовать?” вторичный.
Сначала надо понять:
-
это значение должно жить годами или умереть через 15 минут?
-
его можно показывать пользователю или оно должно оставаться секретом?
-
оно участвует в связях между таблицами или только красиво выглядит в URL?
-
оно должно пережить миграцию базы?
-
по нему человек будет искать сущность в саппорте?
-
его можно безопасно логировать?
И только потом выбирать формат: bigint, UUID, ObjectId, slug, случайный токен или что-то своё.
ID: стабильный идентификатор сущности
ID нужен, чтобы отличить одну сущность от другой.
{ "id": 123, "email": "alice@example.com", "created_at": "2026-06-16T10:15:00Z"}
Или так:
{ "id": "550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "created_at": "2026-06-16T10:15:00Z"}
Ключевой момент: ID — это про идентичность сущности, а не про способ доступа к ней.
Хороший ID обычно:
-
стабилен;
-
уникален в своём пространстве;
-
хорошо индексируется;
-
не меняется из-за переименования, смены email, заголовка или статуса;
-
может участвовать в связях между сущностями.
Например, пользователь может сменить email, но сам пользователь от этого не должен резко стать другой сущностью.
Статья может сменить заголовок, но комментарии, лайки, история публикации и права редакторов должны продолжать ссылаться на ту же статью.
Вот здесь ID и нужен: он держит идентичность, пока остальные атрибуты спокойно меняются.
Поэтому строить ID из изменяемых данных — почти всегда плохая идея.
{ "id": "alice@example.com"}
Сегодня это выглядит удобно. Завтра пользователь меняет email, и система начинает делать вид, что перед ней новый человек. Плохо? Очень.
email может быть логином. slug может быть адресом. username может быть публичным именем. Но всё это не обязано быть главным идентификатором сущности.
UUID — это не отдельный смысл, а формат ID
UUID часто обсуждают так, будто это самостоятельная архитектурная роль.
550e8400-e29b-41d4-a716-446655440000
Но UUID — это формат значения. Он не говорит, зачем это значение существует.
UUIDv4 обычно выбирают, когда нужен случайный идентификатор, который можно генерировать без центрального счётчика. UUIDv7 — когда хочется сохранить тот же формат, но получить отсортированные по времени значения, а как следствие и более производительную запись с точки зрения индексов.
Это полезные свойства формата, но они не отвечают на главный вопрос: что именно вы моделируете?
Проблемы начинаются, когда команда видит UUID и перестаёт думать о роли значения. UUID длинный и выглядит серьёзно, но это всё ещё просто формат.
Внутренний ID и публичный ID — разные контракты
Не каждый внутренний ID обязан торчать наружу.
Внутри базы может быть так:
{ "id": 123456, "public_id": "usr_01J2Y4K7YQ7V8K9W2V7R4D5H3A", "email": "alice@example.com", "created_at": "2026-06-12T10:15:00Z"}
А наружу можно отдавать так:
{ "public_id": "usr_01J2Y4K7YQ7V8K9W2V7R4D5H3A", "email": "alice@example.com"}
Это не обязательное правило на каждый проект. Для некоторых проектов отдельный public_id может быть лишней церемонией, которая ощутимого профита и не даст.
Но если API становится внешним контрактом, то разделение быстро начинает окупаться.
Внутренний ID оптимизирует хранение и связи.
Публичный ID стабилизирует внешний мир: API, события, интеграции, ссылки, тикеты поддержки, экспорт данных.
id можно поменять вместе с базой, способом шардинга или внутренней моделью хранения.public_id лучше не менять без очень веской причины, потому что на него уже могли завязаться клиенты, интеграции, webhooks и чужие логи.
Префиксы вроде usr_, ord_, inv_ не делают значение безопаснее. Но они делают систему удобнее для людей.
usr_01J2Y4K7YQ7V8K9W2V7R4D5H3Aord_01J2Y4NDZJ4ZMK8H8EVNQX3JWRinv_01J2Y4P0A1TRR4E0VD7J4FPX5K
Когда такой ID прилетает в тикет поддержки, уже видно, что это за сущность. Не надо будет каждый раз гадать на кофейной гуще.
Главная идея простая: внутренний ID принадлежит вашей реализации, публичный ID принадлежит вашему контракту.
Иногда это оверхед, но далеко не всегда.
Slug — это адрес для людей, а не идентичность сущности
slug нужен, чтобы URL можно было прочитать глазами.
curl https://example.com/articles/id-token-uuid-slug
Он хорош для публикаций, профилей, категорий, документации, лендингов и всего, что человек может увидеть, скопировать и отправить в чат.
Но slug живёт в мире текста, а текст имеет свойство меняться.
Название статьи поменяли, SEO поправили, компания переименовала продукт, или автор решил, что старый заголовок был унылым.
Если slug был просто адресом — ничего страшного. Можно хранить историю и делать редиректы.
{ "article_id": 7421, "old_slug": "id-token-uuid-slug", "new_slug": "identifiers-tokens-and-slugs", "created_at": "2026-06-12T10:00:00Z"}
Но если slug стал идентичностью сущности, начинается боль.
{ "article_slug": "id-token-uuid-slug", "comment": "..."}
Лучше держать связь через стабильный ID, а slug оставить там, где ему место: на границе с человеком.
Token — это значение с миссией и сроком смерти
Токен отличается от ID не длиной и не тем, что выглядит более случайно.
Токен отличается назначением.
ID указывает на сущность.token даёт возможность выполнить конкретное действие или подтвердить конкретный контекст.
Например, reset-token:
curl -X POST https://example.com/password-reset/consume \ -d '{"token":"pYp9cBW9wI8o4ZmU3uY1L2zYqR8b3S..."}'
У такого значения должна быть история:
-
зачем оно создано;
-
для какой сущности;
-
когда истекает;
-
можно ли использовать его повторно;
-
отозвано ли оно;
-
как оно хранится.
Поэтому запись токена обычно выглядит не как ещё один ID, а как отдельная сущность:
{ "token_hash": "3b7f9c7a7c1d...", "user_id": 123, "purpose": "password_reset", "expires_at": "2026-06-12T10:30:00Z", "used_at": null, "created_at": "2026-06-12T10:00:00Z"}
Обратите внимание: в базе лежит token_hash, а не сырой токен.
Это не потому что так красивее, просто токен — это credential. Если он утёк, то его можно будет использовать в плохих целях.
Поэтому хранить его как обычное поле token рядом с user_id — плохая привычка. Это удар по безопасности.
Сырой токен показывается один раз: в письме, ссылке или ответе API. Потом система работает с его хэшем, без постоянного хранения, без отображений в логах.
Вот здесь и видна разница.
user_id должен жить долго и стабильно указывать на пользователя.reset_token должен выполнить одну задачу и исчезнуть из активной жизни.
Если значение должно умереть, быть отозвано, иметь конкретное назначение или лимит использования — это уже пахнет токеном, а не идентификатором.
В памятке у OWASP в Forgot Password Cheat Sheet похожая логика: reset-token должен быть случайным, одноразовым и ограниченным по времени.
Но в рамках этой статьи важнее не security-чеклист, а сама модель: токен — это не айдишка подлиннее, а отдельный объект с жизненным циклом.
API-key — это секрет, а не имя клиента
API-key часто живёт рядом с client_id, и из-за этого их любят смешивать.
curl -H 'X-API-Key: 9f3c0e7d1a2b...' https://api.example.com/orders
Но client_id и api_key отвечают на разные вопросы.
client_id: кто этот клиент?api_key: чем он доказывает, что имеет право использовать интеграцию?
Плохая модель:
{ "client_id": "partner-crm", "api_key": "9f3c0e7d1a2b...", "enabled": true}
Она выглядит компактно, но делает секрет уязвимым и не отвечает на вопросы о ротации, дате создания, дате отзыва и прочем.
Более здоровая модель позволяет уместить все эти вещи:
{ "id": 981, "client_id": "partner-crm", "key_prefix": "tkn_7xQd", "key_hash": "f0c3a2...", "expires_at": null, "revoked_at": null, "created_at": "2026-06-12T10:00:00Z", "last_used_at": "2026-06-12T10:10:00Z"}
С помощьюclient_id можно выяснять, какому клиенту принадлежит данный ключ.
А key_prefix можно показывать клиенту, чтобы он понимал, какой ключ он смотрит.
Полный api_key лучше не показывать повторно и не хранить открытым текстом. Это та же ошибка, о которой я писал в блоке про token.
Session ID и JWT: два разных способа нести контекст
Session ID — это указатель на серверное состояние.
curl -H 'Cookie: session_id=s%3A7xQd9z...' \ https://example.com/account
Сам по себе session ID обычно ничего не объясняет. Он просто помогает серверу найти сессию.
{ "session_hash": "b24a...", "user_id": 123, "created_at": "2026-06-12T10:00:00Z", "expires_at": "2026-06-19T10:00:00Z", "revoked_at": null}
В этом и смысл: session ID непрозрачен. Вся полезная информация живёт на сервере.
JWT устроен иначе.
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \ https://api.example.com/orders
Согласно RFC 7519, JWT — это компактный способ передавать соглашения между сторонами.
{ "sub": "usr_01J2Y4K7YQ7V8K9W2V7R4D5H3A", "iss": "https://auth.example.com", "aud": "api.example.com", "exp": 1781268000, "scope": "orders:read"}
Здесь важно не устроить религиозную войну “JWT против сессий”. Это отдельная тема.
Для этой статьи достаточно другого вывода:
session_id — это идентификатор серверной сессии.jwt — это токен с значениями.sub внутри JWT — это идентификатор субъекта.
Это три разные роли. Даже если в коде они все проходят как string.
Если хочется глубже именно про внутрянку JWT, есть RFC 8725: JSON Web Token Best Current Practices. Но тащить всю эту тему сюда не стоит.
Request ID — это ID события, а не бизнес-сущности
request_id, trace_id, correlation_id нужны, чтобы связывать события в логах.
curl -H 'X-Request-ID: req_01J2Y51A8ZY2J1X9V7E6D4C3B2' \ https://api.example.com/orders
В логах:
{ "request_id": "req_01J2Y51A8ZY2J1X9V7E6D4C3B2", "method": "POST", "path": "/api/orders", "status": 201, "duration_ms": 84}
Это значение помогает ответить на вопрос: “что происходило в рамках этого запроса?”
Оно не должно становиться ID заказа, пользователя, файла или операции оплаты. Это фундаментальный принцип.
Да, оно тоже уникальное, тоже строка и его удобно прокидывать между сервисами.
Но его роль — наблюдаемость, а не бизнес-модель.
Если по request_id внезапно начинают искать заказ в доменной логике, то что-то явно идет не так. Не катастрофа сама по себе, но хороший индикатор, что границы начали расползаться.
Название поля должно говорить о роли, а не о формате
Плохое имя прячет смысл.
{ "uuid": "550e8400-e29b-41d4-a716-446655440000"}
Что это? ID пользователя? ID заказа? ID события?
Формат есть, а роли нет. Непонятно.
Лучше:
{ "user_id": "550e8400-e29b-41d4-a716-446655440000"}
Или вообще public_user_id, если это внешний контракт.
То же самое с токенами. Плохо называть поле просто id или token, если сама модель не предполагает назначения. Важно сходу иметь ответ на вопрос: «К чему это поле относится и какую проблему решает?»reset_token, reset_token_hash — намного лучше.
Чем меньше приходится гадать по названию поля, тем меньше шансов, что через год его начнут использовать не по назначению.
Одна таблица, чтобы не мешать всё в кашу
|
Значение |
Главный вопрос |
Живёт долго? |
Можно показывать? |
Можно менять? |
|---|---|---|---|---|
|
|
Какая это сущность? |
Да |
Зависит от границы |
Почти никогда |
|
|
Как сослаться на сущность снаружи? |
Да |
Да |
Очень нежелательно |
|
|
В каком формате записан ID? |
Зависит от роли |
Зависит от роли |
Зависит от роли |
|
|
Как сделать адрес читаемым? |
Может меняться |
Да |
Да, с историей и редиректами |
|
|
Почему это действие можно выполнить? |
Обычно нет |
Только в момент выдачи |
Скорее отозвать/перевыпустить |
|
|
Чем интеграция доказывает доступ? |
Иногда |
Нет, кроме префикса |
Через rotation |
|
|
Где серверная сессия? |
Нет |
Только клиенту-владельцу |
Отозвать/создать заново |
|
|
Как найти запрос в логах? |
Пока живут логи/трейсы |
Да |
Нет смысла менять |
Удобная проверка на здравый смысл: если вы не можете заполнить такую таблицу для значения, которое добавляете в API, то вы пока не спроектировали модель. Вы просто выбрали строку.
Что почитать по матчасти
Не чтобы превратить статью в список памяток RFC, а чтобы было куда ткнуть, когда спор снова скатится в “мне кажется”.
-
RFC 9562: Universally Unique IDentifiers — актуальная спецификация
UUID, включаяUUIDv4иUUIDv7. -
RFC 7519: JSON Web Token — базовая спецификация
JWT. -
RFC 8725: JSON Web Token Best Current Practices — практики безопасного использования JWT.
-
OWASP Forgot Password Cheat Sheet — требования к
reset-token. -
OWASP Session Management Cheat Sheet — требования к
session ID.
Вывод
Проблема не в том, что кто-то выбрал UUID вместо integer.
Проблема начинается, когда формат значения начинают путать с его ролью.
Они могут быть строками. Могут быть UUID. Могут выглядеть одинаково в JSON и лететь через один и тот же HTTP.
Но в системе они живут по разным законам.
Сначала роль. Потом срок жизни. Потом границы видимости. Потом хранение. И только потом формат.
Иначе получается классическая магия: всё называется id, всё вроде работает, а потом через год никто не понимает, почему одно поле нельзя переименовать без миграции, другое нельзя логировать, третье нельзя менять, а четвёртое вообще оказалось секретом.
Михаил Миронов, Табрика co-founder.
ссылка на оригинал статьи https://habr.com/ru/articles/1048324/