ID Obfuscation with Sqids: Why Your API Shouldn't Expose Sequential IDs
A competitor once told me they figured out how many customers we had by signing up, looking at their user ID, and subtracting. User 4,218. Four thousand two hundred and eighteen customers. That inform
ID Obfuscation with Sqids: Why Your API Shouldn't Expose Sequential IDs
A competitor once told me they figured out how many customers we had by signing up, looking at their user ID, and subtracting. User 4,218. Four thousand two hundred and eighteen customers. That information should cost money to discover, not be free in the response payload.
Sequential IDs are a gift to scrapers, competitors, and anyone who wants to enumerate your data. They tell people your growth rate (sign up on Monday, sign up on Friday, compare IDs). They make IDOR attacks trivial (change id=42 to id=43 in the URL). They're a security and business intelligence liability.
The Options
UUIDs: 550e8400-e29b-41d4-a716-446655440000. Unique, non-sequential, but ugly. 36 characters. Terrible in URLs. And they're genuinely random, which means they fragment B-tree indexes and hurt database performance on large tables.
ULIDs: 01ARZ3NDEKTSV4RRFFQ69G5FAV. Better than UUIDs — they're sortable by creation time and pack more densely. Still 26 characters, still not pretty in a URL.
Sqids: K4x8Lm. Short, URL-safe, non-sequential. Generated from the actual integer ID using a reversible algorithm. Your database keeps its efficient auto-incrementing integer primary key while your API never exposes it.
I use Sqids on every project now.
How Sqids Work
Sqids (the successor to Hashids) takes an integer and produces a short string:
use Sqids\Sqids;
$sqids = new Sqids(alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789', minLength: 6);
$sqids->encode([1]); // 'k4x8lm'
$sqids->encode([2]); // 'r9z2qp'
$sqids->encode([1000]); // 'v7w3nj'
$sqids->decode('k4x8lm'); // [1]
It's not encryption — it's obfuscation. The algorithm is reversible with the same configuration. But without knowing the alphabet order and salt, you can't predict the output from the input. That's enough to prevent casual enumeration.
The important thing: your database still uses integer IDs internally. You get all the performance benefits of auto-incrementing integers (sequential inserts, compact indexes, fast joins) while your API presents opaque identifiers.
The HasUid Trait
Rather than manually encoding/decoding IDs everywhere, I use a trait that handles it automatically:
// app/Concerns/HasUid.php
namespace App\Concerns;
use Sqids\Sqids;
trait HasUid
{
public function getUidAttribute(): string
{
return $this->encodeId($this->id);
}
public static function findByUid(string $uid): ?static
{
$id = static::decodeUid($uid);
if ($id === null) {
return null;
}
return static::find($id);
}
public static function findByUidOrFail(string $uid): static
{
$id = static::decodeUid($uid);
if ($id === null) {
abort(404);
}
return static::findOrFail($id);
}
public static function decodeUid(string $uid): ?int
{
$sqids = static::getSqidsInstance();
$decoded = $sqids->decode($uid);
return $decoded[0] ?? null;
}
protected function encodeId(int $id): string
{
return static::getSqidsInstance()->encode([$id]);
}
protected static function getSqidsInstance(): Sqids
{
return new Sqids(
alphabet: config('sqids.alphabet'),
minLength: config('sqids.min_length', 6),
);
}
}
Apply it to any model:
class Booking extends Model
{
use HasUid;
// ...
}
$booking = Booking::find(42);
$booking->uid; // 'k4x8lm'
$booking = Booking::findByUid('k4x8lm');
$booking->id; // 42
Route Model Binding
The real power comes when you wire UIDs into route model binding. Instead of /bookings/42, your URLs look like /bookings/k4x8lm:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Route::bind('booking', function (string $value) {
return Booking::findByUidOrFail($value);
});
}
Or, if you want it automatic for all models using the trait, override resolveRouteBinding on the model:
trait HasUid
{
// ... other methods ...
public function resolveRouteBinding($value, $field = null): ?static
{
if ($field) {
return parent::resolveRouteBinding($value, $field);
}
return static::findByUidOrFail($value);
}
}
Now your routes work with UIDs automatically:
Route::get('/bookings/{booking}', [BookingController::class, 'show']);
A request to /bookings/k4x8lm resolves the booking by decoding the UID, fetching by integer ID. Fast, clean, no sequential IDs in the URL.
Configuration
Keep your Sqids config in a dedicated config file:
// config/sqids.php
return [
'alphabet' => env('SQIDS_ALPHABET', 'abcdefghijklmnopqrstuvwxyz0123456789'),
'min_length' => env('SQIDS_MIN_LENGTH', 6),
];
The alphabet is your secret sauce. Shuffle it for each project — this means the same integer ID produces different UIDs across different applications. Even if someone figures out you're using Sqids, they can't decode your IDs without knowing your alphabet.
Important: don't change the alphabet after you've gone to production. Existing UIDs (bookmarked URLs, saved links, API integrations) would break.
Frontend Considerations
When using Inertia, I pass UIDs in the data transfer objects:
readonly class BookingData
{
public function __construct(
public string $uid,
public string $venueName,
public string $date,
public string $status,
) {}
public static function fromModel(Booking $booking): self
{
return new self(
uid: $booking->uid,
venueName: $booking->venue->name,
date: $booking->date->format('d M Y'),
status: $booking->status->value,
);
}
}
The frontend never sees the integer ID. Links use the UID:
import { Link } from '@inertiajs/react';
function BookingRow({ booking }: { booking: BookingData }) {
return (
<tr>
<td>{booking.venueName}</td>
<td>{booking.date}</td>
<td>
<Link href={`/bookings/${booking.uid}`}>
View
</Link>
</td>
</tr>
);
}
API Resources
For API responses, the same approach:
class BookingResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->uid, // UID, not integer ID
'venue' => new VenueResource($this->whenLoaded('venue')),
'date' => $this->date->toIso8601String(),
'status' => $this->status,
'created_at' => $this->created_at->toIso8601String(),
];
}
}
Notice I map uid to the id key in the response. External consumers see id: "k4x8lm" and have no idea there's an integer underneath. This is deliberate — the external identifier is the UID.
Performance
The encoding/decoding is pure computation — no database queries, no cache lookups. Sqids encode/decode operations take microseconds. The database still queries by integer primary key, which is as fast as it gets.
The only overhead is the encode call when serialising models. On a list of 100 bookings, you're adding maybe 0.1ms total. Not measurable.
Common Objections
"Just use UUIDs." If your URLs don't matter to you and you don't care about index fragmentation, sure. For web apps where URLs are visible to users, booking/k4x8lm is nicer than booking/550e8400-e29b-41d4-a716-446655440000.
"It's security through obscurity." Yes. And that's fine for this use case. Sqids don't replace authorisation — you still need policies that prevent unauthorised access. Sqids prevent casual enumeration and business intelligence leaking. They're one layer in a defence-in-depth strategy.
"What if someone reverse-engineers the alphabet?" Then they can decode your IDs. So what? Your authorisation layer should prevent them from accessing resources they shouldn't. The UIDs prevent drive-by scraping, not determined attackers.
The Setup Checklist
- Install Sqids:
composer require sqids/sqids - Create
config/sqids.phpwith a shuffled alphabet - Add the
HasUidtrait to models that need it - Update route model binding
- Use
uidin DTOs and API resources instead ofid - Never expose integer IDs externally
It takes about 30 minutes to set up across a project. The ROI in reduced scraping, better URLs, and zero business intelligence leaking is permanent.
I write about Laravel, AI tooling, and building software. More at stuartmason.co.uk.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.