March 16, 2026
The Dual-ID Pattern: Stop Exposing Integer Primary Keys in Your API
Returning database IDs like 1, 2, 3 in your API is a quiet security mistake. Here's a two-field fix that keeps your internals private.
Most APIs start with a simple convention: the database assigns an integer ID to each record, and the API returns it. A newly created user gets id: 1, the next gets id: 2, and so on.
That's convenient. It's also leaking information most teams don't mean to share, and it's the kind of default worth flagging on every backend built this way.
The Enumeration Attack
When an API exposes sequential integer IDs, anyone can enumerate the data. If GET /api/users/1 returns a user, a script can loop from 1 to 10,000 and scrape every user record the API returns, even without any authentication bypass.
Beyond scraping, sequential IDs reveal business metrics. A competitor can track order volume by placing two orders a week apart and comparing IDs. A new user can tell exactly how many users signed up before them.
Integer IDs are optimized for the database, not for API clients. Fast joins, compact indexes, auto-increment: those are internal concerns. There's no reason a client needs to know the shape of a primary key.
The Fix: Two IDs, One Model
The dual-ID pattern applies to every model that gets exposed via API. It gives each model two identifiers:
id: integer primary key, used internally for joins and foreign keyspublic_id: UUID, used in all external API surfaces
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
import uuid
class PublicIdMixin:
public_id: Mapped[str] = mapped_column(
String(36),
unique=True,
index=True,
default=lambda: str(uuid.uuid4())
)Add this mixin to any model that gets exposed via API:
class User(Base, TimestampMixin, PublicIdMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)Internal vs External Query Patterns
The split carries through to how records get queried:
# Internal: use integer id for performance-critical operations
# (joins, foreign key lookups, bulk operations)
user = await session.get(User, user_id)
# External: use public_id for anything coming from the API
user = (
await session.execute(select(User).where(User.public_id == public_id))
).scalar_one_or_none()Foreign keys between tables still use integer IDs, and that's the whole point. The database's relational performance stays intact. The UUID only surfaces at the API boundary.
The response schema never includes id:
class UserResponse(BaseModel):
public_id: str # ← exposed
email: str
name: str
created_at: datetime
# id is intentionally absentWhat You Get
An API that returns public_id: "a1b2c3d4-e5f6-..." instead of id: 42:
- Reveals nothing about record count or sequence
- Can't be enumerated without a valid UUID to start from
- Allows the internal schema to change (e.g. switching to a different PK strategy) without breaking API clients
- Makes it obvious at a glance which identifier is safe to share externally
The mixin is two fields and five lines of code. It's one of the lowest-effort security improvements a backend can start with, and one of the harder ones to retrofit once an API is already in use. Worth applying by default on every project, not just the ones where a client asks for it.