Introduction
Auto incremented IDs are the most used, preferred, and efficient form of storing primary keys in SQL databases. However displaying these IDs plainly in your API URL, may leak unintended data to your end users/public.
Consider the following scenarios in which auto-incremented primary keys are plainly shown in the URL:
- We have a GET /api/users/:id endpoint. Anyone will be able to guess how many users we have right now by creating a new account and simply viewing their user id.
- We have a GET /api/products/:id/purchase/:id endpoint which allows users to buy a product. A competing business can purchase a product from your platform, after a given time such as a day they make another purchase. By simply looking at the difference between the IDs, they can find out how many products your business has sold within the given time frame.
In short by doing this you may be providing your users with information they don’t need to have.
An additional reason to avoid using auto-incremented IDs in the URL is that they, in my opinion at least, just do not look good:
/api/posts/19402425483
looks uglier than/api/posts/zd4fTddF
There are two possible solutions to the aforementioned problems:
- Stop using auto-incremented IDs altogether, use a different primary key such as UUID or nanoID, although this solution comes with its caveats.
- Keep using auto-incremented IDs internally, but disguise, or obfuscate them when they are displayed to the public as well as receive hashids instead of plain ids from client requests.
In this article, we’ll take the second approach and we’ll implement a solution in Laravel using the Hashids package.
Import to note
The term obfuscation used in this article’s title and on the official docs of the hashids docs is not unintended. It’s mentioned in the docs that hashids are not meant to be very secure and there are ways to decode them even without a salt. Despite this, they are certainly more secure than showing plain IDs and they make it way harder to find out the real IDs.
Implementation in Laravel
First, we need to install the Hashids library:
composer require hashids/hashids
Now we can use it to obfuscate our IDs. To make it easier for us to use this package, we will create a simple wrapper class. I like putting most custom and shared logic inside a services folder, thus we’ll create the following class:
<ㅤ?php namespace App\Services; use Hashids\Hashids; class HashIdService { public function __construct( public $hashIds = new Hashids('Laravel Hashids Example', 10), ) {} public function encode($id) { return $this->hashIds->encode($id);
}
public function decode($hashId)
{
if(is_int($hashId))
return $hashId;
return $this->hashIds->decode($hashId)[0];
}
}
With this class in place, we can encode and decode our IDs. However, doing these actions repeatedly every time we need to access or return an ID would be extremely cumbersome( I’ve seen this done in real projects 😐). After all one of the most basic principles of programming is avoiding manual repetition and automating processes.
Since we want to get hashids as route parameters instead of plain IDs, we need to decode them every time we want to interact with our database. We’ll make this process painless, by using route binding in the RouteServiceProvider.php file:
<ㅤ?php namespace App\Providers; use App\Services\HashIdService; use Exception; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; class RouteServiceProvider extends ServiceProvider { /** * The path to the "home" route for your application. * * This is used by Laravel authentication to redirect users after login. * * @var string */ public const HOME = '/home'; /** * Define your route model bindings, pattern filters, etc. * * @return void */ public function boot() { $this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
// add this
Route::bind('id', function($hashId){
try{
return (new HashIdService())->decode($hashId);
}catch(Exception $e){
abort(404, 'No item found with this id!');
}
});
}
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
}
Now every time we receive a hashid as a route parameter it will automatically be decoded, then we can use it as a regular id in our controllers.
The next step is to encode the id to its hashid counterpart when we need to return a response.
The API resource approach
You can use API resources to handle the encoding of IDs very smoothly. For each model, you should create an API resource and transform the plain ID. Here’s an example:
<ㅤ?php namespace App\Http\Resources; use App\Services\HashIdService; use Illuminate\Http\Resources\Json\JsonResource; class PostResource extends JsonResource { /** * Transform the resource into an array. * * @param \Illuminate\Http\Request $request * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ public function toArray($request) { return [ 'id' => (new HashIdService())->encode($this->id),
'title' => $this->title,
'content' => $this->content,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
Finally, your routes will look as such:
<ㅤ?php
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/posts', function(){
return PostResource::collection(Post::all());
});
Route::get('/posts/{id}', function ($id) {
return new PostResource(Post::find($id));
});
Using API resources in this case and in general is very convenient and neat, however, in a real-world project, there are cases when you may need to return custom or complex responses, which may not be based on resources. Follow along…
The custom cast approach
We will create a custom cast to handle setting and getting our IDs:
<ㅤ?php namespace App\Casts; use App\Services\HashIdService; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; class HashId implements CastsAttributes { /** * Cast the given value. * * @param \Illuminate\Database\Eloquent\Model $model * @param string $key * @param mixed $value * @param array $attributes * @return mixed */ public function get($model, string $key, $value, array $attributes) { return (new HashIdService())->encode($value);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return mixed
*/
public function set($model, string $key, $value, array $attributes)
{
return (new HashIdService())->decode($value);
}
}
Using this custom cast, every time you retrieve a model the id will automatically be converted to its hashid counterpart. Similarly, if we want to set the id it will try to decode the hashid and set the raw id value. The last step is to add our cast to the id field:
<ㅤ?php namespace App\Models; use App\Casts\HashId; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasFactory; protected $casts = [ 'id' => HashId::class
];
}
Great, now all our ids are converted to hashids when accessed, but what if we need to access the raw original id? After all, that is the id we will use to store data in the database. Worry not Laravel has this covered by providing us with the very convenient getRawOriginal and setRawAttributes methods, which allow you to skip casts, mutators, and accessors.
Lastly, here’s how our routes look like:
<ㅤ?php use App\Http\Resources\PostResource; use App\Models\Post; use App\Services\HashIdService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; Route::get('/posts', function(){ return Post::all(); }); Route::get('/posts/{id}', function ($id) { $post = Post::find($id); // if you need to get the original id // $id = $post->getRawOriginal('id');
return $post;
});
Before:
After: