A complete guide to implementing Stripe-style public IDs (po_a8Kx3mNp2qR1) in a Rails application. Public IDs replace raw database IDs in URLs, APIs, and frontend code — preventing enumeration attacks and leaking internal state.
- Security: Sequential integer IDs leak record counts and are trivially enumerable
- Portability: Prefixed IDs (
us_,po_,ch_) are self-describing across logs, support tickets, and APIs - Decoupling: Frontend and API consumers never depend on internal database IDs
┌─────────────────────────────────────────────────────────┐
│ Database: public_id column (string, unique, NOT NULL) │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ Concern: PublicIdentifiable │
│ - before_create :set_public_id │
│ - to_param returns public_id │
│ - resolve_public_id(pid) class method │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ ApplicationRecord includes PublicIdentifiable │
│ → All models inherit public_id behavior │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
Controllers Serializers Frontend
find_by! Alba base TypeScript
(public_id:) resource types via
auto-exposes Typelizer
public_id
| File | Purpose |
|---|---|
02_public_identifiable.rb |
Core concern — ID generation, lookup, to_param |
03_application_record.rb |
Include the concern in all models |
04_migration.rb |
Add public_id column to existing tables |
05_application_resource.rb |
Alba base resource that auto-serializes public_id |
06_no_raw_id_in_resources.rb |
RuboCop cop preventing :id exposure in serializers |
07_public_identifiable_test.rb |
Minitest coverage |
Drop 02_public_identifiable.rb into app/models/concerns/public_identifiable.rb.
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
include PublicIdentifiable
endGenerate and run a migration based on 04_migration.rb. Customize TABLES_WITH_PREFIXES with your own tables and 2-3 character prefixes.
bin/rails generate migration AddPublicIdToAllTables
# Paste the migration content, then:
bin/rails db:migrateEvery model must declare its prefix:
class User < ApplicationRecord
public_id_prefix "us"
end
class Post < ApplicationRecord
public_id_prefix "po"
end
class Channel < ApplicationRecord
public_id_prefix "ch"
endPick short, unique, lowercase prefixes. Keep a registry (the migration itself serves as one).
Replace find(params[:id]) with find_by!(public_id: params[:id]):
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
private
def set_post
@post = current_workspace.posts.find_by!(public_id: params[:id])
end
endSince to_param returns public_id, Rails route helpers automatically generate URLs with public IDs — no changes needed in views or link helpers.
Drop 05_application_resource.rb into app/resources/application_resource.rb. Every resource inheriting from it automatically includes public_id in its JSON output.
For relationship references, expose the associated record's public_id:
class PostResource < ApplicationResource
attributes :title, :content, :status
attribute :author_public_id do |post|
post.author&.public_id
end
endDrop 06_no_raw_id_in_resources.rb into lib/rubocop/cop/your_app/no_raw_id_in_resources.rb and enable it in .rubocop.yml:
require:
- ./lib/rubocop/cop/your_app/no_raw_id_in_resources
YourApp/NoRawIdInResources:
Enabled: true
Include:
- "app/resources/**/*.rb"This prevents anyone from accidentally writing attributes :id, :name in a serializer.
With Typelizer, TypeScript types are auto-generated from Alba resources:
// Auto-generated type
type Post = {
public_id: string
title: string
content: string
author_public_id: string | null
}Use public_id everywhere in frontend code:
// Routing
router.visit(`/posts/${post.public_id}`)
// Lookups
const selected = posts.find(p => p.public_id === selectedId)
// API calls
await fetch(`/api/v1/posts/${post.public_id}`, { method: "PATCH", ... })- Shorter: 12 chars vs 36 chars — better for URLs and logs
- Prefixed:
po_a8Kx3mNp2qR1is instantly recognizable as a Post - URL-safe: No hyphens or special characters
- Collision-resistant: 62^12 = ~3.2 × 10^21 possible values per prefix
- IDs are only needed for persisted records
- Avoids unnecessary generation for transient objects (
Post.newfor forms) - Collision retry logic needs database checks — only makes sense at create time
- DB unique index: Absolute guarantee, catches race conditions
- App validation: Better error messages, avoids DB round-trips for known duplicates
Rails calls to_param when generating URLs from model objects. Overriding it means post_path(@post) automatically uses the public_id — zero changes needed in existing views, helpers, or link generation.