Sequential database IDs in URLs expose information about your application. A user seeing /invoices/1847 knows roughly how many invoices exist and can easily guess other valid IDs. Rails 8 provides a built-in solution: signed IDs that create tamper-proof, opaque tokens for any Active Record model.
The Problem with Sequential IDs
Consider a typical invoice URL: /invoices/523. This reveals that invoice #523 exists, implies there are at least 522 others, and invites enumeration attacks. Even with proper authorization, exposing sequential IDs leaks business intelligence and creates unnecessary attack surface.
UUIDs solve some problems but create others: they're ugly in URLs, harder to communicate verbally, and require database schema changes. Signed IDs offer a middle groundβkeeping integer primary keys in the database while presenting secure tokens in URLs.
Basic Signed ID Implementation
Every Active Record model in Rails 8 includes signed ID support out of the box. No gems, no configuration required for basic usage:
# app/models/invoice.rb
class Invoice < ApplicationRecord
belongs_to :customer
has_many :line_items
end
# Generating signed IDs (in console or anywhere)
invoice = Invoice.find(523)
invoice.signed_id
# => "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik5USXoiLCJleHAiOm51bGwsInB1ciI6Imludm9pY2UifX0=--a1b2c3d4..."
# Finding records by signed ID
Invoice.find_signed("eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik5USXoiLC...")
# => #
# Invalid or tampered tokens return nil
Invoice.find_signed("tampered_token")
# => nil
# Or raise an exception with bang method
Invoice.find_signed!("tampered_token")
# => raises ActiveRecord::RecordNotFoundThe signed ID encodes the record's ID and model name, then signs it with your application's secret key base. Any modification to the token invalidates it completely.
Building Secure Controllers
Integrating signed IDs into controllers requires a shift in how routes and finders work:
# config/routes.rb
Rails.application.routes.draw do
resources :invoices, param: :signed_id
resources :shared_documents, param: :token, only: [:show]
end
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :authenticate_user!
before_action :set_invoice, only: [:show, :edit, :update]
def show
end
def share
@invoice = current_user.invoices.find_signed!(params[:signed_id])
# Generate a time-limited sharing token
@share_url = shared_document_url(token: @invoice.signed_id(expires_in: 7.days))
end
private
def set_invoice
@invoice = current_user.invoices.find_signed!(params[:signed_id])
rescue ActiveRecord::RecordNotFound
redirect_to invoices_path, alert: "Invoice not found or access denied"
end
end
# app/controllers/shared_documents_controller.rb
class SharedDocumentsController < ApplicationController
# No authentication required - the signed ID IS the authorization
def show
@invoice = Invoice.find_signed(params[:token], purpose: :sharing)
if @invoice.nil?
render :expired_link and return
end
end
endNotice how find_signed! scoped to current_user.invoices provides both authentication (valid token) and authorization (belongs to user) in one query.
Expiring Tokens and Purposes
Signed IDs become powerful when combined with expiration times and purposes. This enables secure, time-limited sharing without separate token tables:
# app/models/invoice.rb
class Invoice < ApplicationRecord
belongs_to :customer
# Generate different tokens for different uses
def shareable_token(duration: 7.days)
signed_id(expires_in: duration, purpose: :sharing)
end
def payment_link_token
signed_id(expires_in: 24.hours, purpose: :payment)
end
def pdf_download_token
signed_id(expires_in: 1.hour, purpose: :pdf_download)
end
end
# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
skip_before_action :authenticate_user!
def new
# Only accepts tokens generated with purpose: :payment
@invoice = Invoice.find_signed(params[:token], purpose: :payment)
unless @invoice
render :invalid_payment_link, status: :unprocessable_entity
return
end
redirect_to invoice_path(@invoice.signed_id), notice: "Already paid" if @invoice.paid?
end
def create
@invoice = Invoice.find_signed!(params[:token], purpose: :payment)
PaymentProcessor.charge(
amount: @invoice.total_cents,
payment_method: params[:payment_method_id]
)
@invoice.mark_as_paid!
redirect_to payment_confirmation_path(@invoice.signed_id)
end
endThe purpose parameter ensures tokens can't be reused across different features. A sharing token won't work for payments, even if both are valid and unexpired.
URL Helpers and View Integration
Clean up views by creating dedicated URL helpers that automatically use signed IDs:
# app/helpers/invoices_helper.rb
module InvoicesHelper
def invoice_path_signed(invoice)
invoice_path(signed_id: invoice.signed_id)
end
def invoice_url_signed(invoice)
invoice_url(signed_id: invoice.signed_id)
end
def shareable_invoice_url(invoice, duration: 7.days)
shared_document_url(token: invoice.signed_id(expires_in: duration, purpose: :sharing))
end
end
# app/views/invoices/index.html.erb
<% @invoices.each do |invoice| %>
<%= link_to invoice.number, invoice_path_signed(invoice) %>
<%= number_to_currency(invoice.total) %>
<%= button_to "Copy Share Link",
copy_share_link_invoice_path(signed_id: invoice.signed_id),
method: :post,
data: { turbo_frame: "_top" } %>
<% end %>Testing Signed IDs
Testing requires generating valid tokens within specs:
# spec/requests/invoices_spec.rb
RSpec.describe "Invoices", type: :request do
let(:user) { create(:user) }
let(:invoice) { create(:invoice, customer: user.customer) }
describe "GET /invoices/:signed_id" do
context "with valid signed ID" do
it "returns the invoice" do
sign_in user
get invoice_path(signed_id: invoice.signed_id)
expect(response).to have_http_status(:success)
expect(response.body).to include(invoice.number)
end
end
context "with tampered signed ID" do
it "redirects with error" do
sign_in user
get invoice_path(signed_id: "tampered_#{invoice.signed_id}")
expect(response).to redirect_to(invoices_path)
expect(flash[:alert]).to eq("Invoice not found or access denied")
end
end
context "with expired token" do
it "rejects the request" do
sign_in user
expired_token = invoice.signed_id(expires_in: -1.day)
get invoice_path(signed_id: expired_token)
expect(response).to redirect_to(invoices_path)
end
end
end
describe "GET /shared_documents/:token" do
it "allows access without authentication" do
token = invoice.signed_id(expires_in: 7.days, purpose: :sharing)
get shared_document_path(token: token)
expect(response).to have_http_status(:success)
end
it "rejects tokens with wrong purpose" do
payment_token = invoice.signed_id(purpose: :payment)
get shared_document_path(token: payment_token)
expect(response.body).to include("expired")
end
end
endCommon Mistakes to Avoid
Several pitfalls await the unwary:
- Caching signed IDs: Tokens with expiration times should be generated fresh, not stored in the database or cached
- Ignoring purposes: Always specify a purpose for sensitive operations; generic signed IDs should only grant read access
- Forgetting scopes: Use
current_user.invoices.find_signed!notInvoice.find_signed!when authorization matters - Long expiration times: Payment and sensitive action tokens should expire in hours, not days
When to Use Signed IDs
Signed IDs work best for: shareable links, email action links, payment URLs, temporary access tokens, and any public-facing URL where enumeration is a concern. They're less suitable for: internal admin interfaces, API endpoints expecting stable identifiers, or situations requiring URL bookmarking across deployments (rotating secret key base invalidates all tokens).
For applications already using sequential IDs in URLs, signed IDs provide a zero-migration path to more secure URLs. The database keeps integers; the outside world sees cryptographic tokens.