Rails 8 Signed IDs for Secure URLs

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::RecordNotFound

The 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
end

Notice 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
end

The 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
end

Common 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! not Invoice.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.

10 claps
← Back to Blog