Skip to content

Instantly share code, notes, and snippets.

@giacope
Created April 14, 2026 06:42
Show Gist options
  • Select an option

  • Save giacope/f6f9ded6bd694a8368edb778e240064a to your computer and use it in GitHub Desktop.

Select an option

Save giacope/f6f9ded6bd694a8368edb778e240064a to your computer and use it in GitHub Desktop.
Stripe-style Public IDs for Rails + Inertia.js — concern, migration, Alba serializers, RuboCop cop, and tests

Stripe-Style Public IDs for Rails + Inertia.js

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.

Why Public IDs?

  • 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

Architecture Overview

┌─────────────────────────────────────────────────────────┐
│  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

Files

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

Step-by-Step Setup

1. Create the Concern

Drop 02_public_identifiable.rb into app/models/concerns/public_identifiable.rb.

2. Include in ApplicationRecord

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  include PublicIdentifiable
end

3. Add the Migration

Generate 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:migrate

4. Declare Prefixes on Each Model

Every 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"
end

Pick short, unique, lowercase prefixes. Keep a registry (the migration itself serves as one).

5. Update Controllers

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
end

Since to_param returns public_id, Rails route helpers automatically generate URLs with public IDs — no changes needed in views or link helpers.

6. Set Up Serializers (Alba + Inertia.js)

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
end

7. Add the RuboCop Cop (Optional but Recommended)

Drop 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.

8. Frontend Usage

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", ... })

Design Decisions

Why SecureRandom.alphanumeric over UUID?

  • Shorter: 12 chars vs 36 chars — better for URLs and logs
  • Prefixed: po_a8Kx3mNp2qR1 is instantly recognizable as a Post
  • URL-safe: No hyphens or special characters
  • Collision-resistant: 62^12 = ~3.2 × 10^21 possible values per prefix

Why before_create instead of after_initialize?

  • IDs are only needed for persisted records
  • Avoids unnecessary generation for transient objects (Post.new for forms)
  • Collision retry logic needs database checks — only makes sense at create time

Why validate uniqueness at both app and DB level?

  • DB unique index: Absolute guarantee, catches race conditions
  • App validation: Better error messages, avoids DB round-trips for known duplicates

Why to_param?

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.

# app/models/concerns/public_identifiable.rb
# frozen_string_literal: true
module PublicIdentifiable
extend ActiveSupport::Concern
ID_LENGTH = 12
MAX_RETRIES = 3
included do
before_create :set_public_id
validates :public_id, uniqueness: true, allow_nil: true
end
class_methods do
def public_id_prefix(prefix = nil)
if prefix
@public_id_prefix = prefix
else
@public_id_prefix
end
end
def resolve_public_id(public_id)
find_by!(public_id: public_id)
end
end
def to_param
public_id || super
end
private
def set_public_id
MAX_RETRIES.times do
self.public_id = generate_public_id
return unless self.class.exists?(public_id: public_id)
end
raise "Failed to generate unique public_id after #{MAX_RETRIES} attempts"
end
def generate_public_id
prefix = self.class.public_id_prefix
raise "#{self.class.name} must define public_id_prefix" unless prefix
"#{prefix}_#{SecureRandom.alphanumeric(ID_LENGTH)}"
end
end
# app/models/application_record.rb
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
include PublicIdentifiable
end
# Then declare prefixes on each model:
#
# class User < ApplicationRecord
# public_id_prefix "us"
# end
#
# class Post < ApplicationRecord
# public_id_prefix "po"
# end
#
# class Channel < ApplicationRecord
# public_id_prefix "ch"
# end
# db/migrate/YYYYMMDDHHMMSS_add_public_id_to_all_tables.rb
# frozen_string_literal: true
class AddPublicIdToAllTables < ActiveRecord::Migration[8.0]
# Customize this hash with your tables and unique 2-3 character prefixes.
TABLES_WITH_PREFIXES = {
users: "us",
posts: "po",
channels: "ch",
comments: "cm",
# Add all your tables here...
}.freeze
def up
# 1. Add nullable column
TABLES_WITH_PREFIXES.each_key do |table|
add_column table, :public_id, :string, null: true
end
# 2. Backfill existing records
TABLES_WITH_PREFIXES.each do |table, prefix|
ids = select_all("SELECT id FROM #{table} WHERE public_id IS NULL").rows.flatten
ids.each do |id|
pid = "#{prefix}_#{SecureRandom.alphanumeric(12)}"
execute "UPDATE #{table} SET public_id = #{quote(pid)} WHERE id = #{id}"
end
end
# 3. Add unique index and set NOT NULL
TABLES_WITH_PREFIXES.each_key do |table|
add_index table, :public_id, unique: true
change_column_null table, :public_id, false
end
end
def down
TABLES_WITH_PREFIXES.each_key do |table|
remove_column table, :public_id
end
end
end
# app/resources/application_resource.rb
#
# Base Alba resource for Inertia.js serialization.
# Automatically includes `public_id` in every resource's JSON output.
#
# Requires:
# gem "alba"
# gem "typelizer" (optional — generates TypeScript types from Alba resources)
class ApplicationResource
include Alba::Resource
# Optional: auto-generate TypeScript types (requires typelizer gem)
# include Typelizer::DSL
# Expose public_id on every resource automatically.
# Uses respond_to? so resources for non-PublicIdentifiable objects still work.
attribute :public_id do |record|
record.respond_to?(:public_id) ? record.public_id : nil
end
end
# Usage — individual resources inherit public_id automatically:
#
# class PostResource < ApplicationResource
# attributes :title, :content, :status, :published_at
#
# # Expose related record's public_id instead of raw foreign key
# attribute :author_public_id do |post|
# post.author&.public_id
# end
# end
#
# Output:
# {
# "public_id": "po_a8Kx3mNp2qR1",
# "title": "Hello World",
# "content": "...",
# "status": "published",
# "published_at": "2025-01-15T10:30:00Z",
# "author_public_id": "us_b9Ly4nOq3rS2"
# }
# lib/rubocop/cop/your_app/no_raw_id_in_resources.rb
#
# RuboCop cop that prevents exposing raw `:id` in Alba resources.
# Catches `attributes :id, :name, ...` and auto-corrects by removing `:id`.
#
# Enable in .rubocop.yml:
#
# require:
# - ./lib/rubocop/cop/your_app/no_raw_id_in_resources
#
# YourApp/NoRawIdInResources:
# Enabled: true
# Include:
# - "app/resources/**/*.rb"
module RuboCop
module Cop
module YourApp
class NoRawIdInResources < Base
extend AutoCorrector
MSG = "Do not expose raw `:id` in resources. Use `public_id` instead."
RESTRICT_ON_SEND = [:attributes].freeze
def on_send(node)
return unless resource_file?
node.arguments.each do |arg|
next unless arg.sym_type? && arg.value == :id
add_offense(arg) do |corrector|
autocorrect(corrector, node, arg)
end
end
end
private
def resource_file?
processed_source.file_path&.include?("app/resources/")
end
def autocorrect(corrector, node, id_arg)
args = node.arguments
if args.size == 1
corrector.remove(node.source_range)
else
remove_argument(corrector, node, id_arg)
end
end
def remove_argument(corrector, node, id_arg)
corrector.remove(removal_range(node.arguments, id_arg))
end
def removal_range(args, id_arg)
idx = args.index(id_arg)
return trailing_range(args, idx) if idx == args.size - 1
leading_range(args, idx)
end
def trailing_range(args, idx)
build_range(args[idx - 1].source_range.end_pos, args[idx].source_range.end_pos)
end
def leading_range(args, idx)
build_range(args[idx].source_range.begin_pos, args[idx + 1].source_range.begin_pos)
end
def build_range(start_pos, end_pos)
Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
end
end
end
end
end
# test/models/concerns/public_identifiable_test.rb
#
# Minitest tests for the PublicIdentifiable concern.
# Adapt fixtures and model references to match your app.
require "test_helper"
class PublicIdentifiableTest < ActiveSupport::TestCase
setup do
@post = posts(:one) # Use your own fixture
end
test "ID_LENGTH is 12" do
assert_equal 12, PublicIdentifiable::ID_LENGTH
end
test "MAX_RETRIES is 3" do
assert_equal 3, PublicIdentifiable::MAX_RETRIES
end
test "sets public_id before create" do
post = Post.create!(title: "test public id", content: "hello")
assert post.public_id.present?
assert post.public_id.start_with?("po_")
end
test "public_id format is prefix underscore alphanumeric" do
assert_match(/\Apo_[A-Za-z0-9]{12}\z/, @post.public_id)
end
test "to_param returns public_id" do
assert_equal @post.public_id, @post.to_param
end
test "resolve_public_id finds record by public_id" do
found = Post.resolve_public_id(@post.public_id)
assert_equal @post, found
end
test "resolve_public_id raises RecordNotFound when not found" do
assert_raises(ActiveRecord::RecordNotFound) do
Post.resolve_public_id("po_nonexistent1")
end
end
test "public_id_prefix returns correct prefix for each model" do
assert_equal "po", Post.public_id_prefix
assert_equal "us", User.public_id_prefix
# Add assertions for your other models
end
test "validates uniqueness of public_id" do
duplicate = Post.new(
title: "duplicate test",
content: "hello",
public_id: @post.public_id
)
assert_not duplicate.valid?
assert duplicate.errors[:public_id].any?
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment