action-controller
Expert guidance for writing Rails controllers with Action Controller. Use when writing controller actions, strong parameters, before_action filters, rendering responses, redirects, flash messages, sessions, cookies, rescue_from error handling, streaming, CSRF protection, or HTTP authentication. Covers params permitting (especially nested params — the
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill action-controllerIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides expert guidance for Rails Action Controller development, including security best practices for parameter permitting and CSRF protection. A minor security risk is identified regarding indirect prompt injection, as the skill directs the agent to read existing project files which could contain malicious instructions.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Rails Action Controller Expert
Write correct, secure, and idiomatic Rails controllers following Rails 8.1 conventions.
Philosophy
- Thin controllers — Business logic belongs in models/services, not controllers
- Strong parameters protect against mass assignment — Raw params let attackers set any attribute (admin flags, user IDs, etc.)
- Convention over configuration — Follow RESTful patterns; fight the urge to add custom actions
- Fail secure — Default to restricting access, then open up selectively
- One controller, one resource — If your controller handles two resources, split it
When To Use This Skill
- Writing new controller actions (CRUD or custom)
- Permitting parameters (especially nested hashes/arrays — this is where bugs live)
- Adding before_action filters for auth/authorization
- Setting up rescue_from for error handling
- Working with sessions, cookies, or flash messages
- Rendering responses or redirecting
- Implementing streaming or file downloads
- Configuring CSRF protection or HTTP auth
Instructions
Step 1: Check Existing Patterns
Look at the project's existing controllers first — consistency with the codebase matters more than textbook patterns:
# See what ApplicationController provides
cat app/controllers/application_controller.rb
# Find similar controllers
ls app/controllers/
# Check for shared concerns
ls app/controllers/concerns/
# Check routes for the resource
bin/rails routes | grep resource_name
Consistency beats "best practice."
Step 2: Controller Structure
Follow this ordering inside every controller:
class ArticlesController < ApplicationController
# 1. Includes/concerns
include Searchable
# 2. Constants (if any)
ITEMS_PER_PAGE = 25
# 3. Callbacks — order matters, they run top-to-bottom
before_action :authenticate_user!
before_action :set_article, only: [:show, :edit, :update, :destroy]
before_action :authorize_article, only: [:edit, :update, :destroy]
# 4. Public actions (RESTful order: index, show, new, create, edit, update, destroy)
def index
@articles = Article.all
end
def show; end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@article.destroy!
redirect_to articles_path, notice: "Article deleted.", status: :see_other
end
# 5. Private methods
private
def set_article
@article = Article.find(params.expect(:id))
end
def authorize_article
redirect_to articles_path, alert: "Not authorized." unless @article.user == current_user
end
def article_params
params.expect(article: [:title, :body, :published])
end
end
Step 3: Strong Parameters
This is where most controller bugs come from — especially with nested hashes and arrays.
Use expect (Rails 8+) Over require + permit
# ✅ Rails 8+ — prefer expect (combines require + permit)
def article_params
params.expect(article: [:title, :body, :published])
end
# ⚠️ Older style — still works but expect is preferred
def article_params
params.require(:article).permit(:title, :body, :published)
end
Nested Hashes — The Danger Zone
# Nested hash (belongs_to address, has fields)
# Form sends: { user: { name: "Jo", address: { street: "123 Main", city: "NY" } } }
def user_params
params.expect(user: [:name, address: [:street, :city, :zip]])
end
# ⚠️ Wrong — this permits NOTHING inside address
def user_params
params.expect(user: [:name, :address]) # address is a hash, not a scalar!
end
Arrays of Scalars
# Array of simple values: { article: { title: "Hi", tag_ids: [1, 2, 3] } }
def article_params
params.expect(article: [:title, tag_ids: []])
end
Arrays of Hashes (accepts_nested_attributes_for)
# Array of nested objects — NOTE THE DOUBLE ARRAY SYNTAX [[...]]
# { project: { name: "X", tasks_attributes: [{ title: "A" }, { title: "B" }] } }
def project_params
params.expect(project: [:name, tasks_attributes: [[:title, :done, :id, :_destroy]]])
end
Double array [[...]] = "I expect an array of hashes, each with these keys."
This is the Rails 8 expect syntax. Agents get this wrong constantly.
Arbitrary Hash (Use Sparingly)
# When you genuinely can't enumerate keys (e.g., JSON metadata blob)
def product_params
params.expect(product: [:name, metadata: {}])
end
# ⚠️ metadata: {} permits ANY keys — only use when truly dynamic
Common Gotchas
| Bug | Fix |
|---|---|
permit(:tags) when tags is an array | permit(tags: []) |
permit(:address) when address is a hash | permit(address: [:street, :city]) |
permit(:images) for file uploads | permit(images: []) for multiple files |
Nested attributes without _destroy and id | permit(items_attributes: [[:name, :id, :_destroy]]) |
Using permit! to "just make it work" | Enumerate your params — permit! allows attackers to set any attribute |
Step 4: Callbacks (before_action, after_action, around_action)
before_action
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :edit, :update, :destroy]
# Skip inherited callbacks selectively
skip_before_action :authenticate_user!, only: [:index, :show]
private
def set_post
@post = Post.find(params.expect(:id))
end
end
Key rules:
- Callbacks run in declaration order — put auth before resource loading
- A callback that renders or redirects halts the chain (remaining callbacks and the action won't run)
- Use
only:/except:to scope callbacks to specific actions skip_before_actiononly works for callbacks inherited from parent classes or registered earlier
around_action
around_action :wrap_in_transaction, only: [:create, :update]
private
def wrap_in_transaction
ActiveRecord::Base.transaction do
yield # executes the action
end
end
The yield is required — without it the action never executes.
Step 5: Rendering Responses
# Implicit render — renders app/views/posts/index.html.erb
def index
@posts = Post.all
# no explicit render needed
end
# Explicit render of a different template
render :new # same controller, different action template
render "posts/new" # cross-controller template
render plain: "OK" # plain text
render json: @post # JSON
render json: @post, status: :created # JSON with status
render html: "<h1>Hi</h1>".html_safe # raw HTML
render inline: "<%= 'hi' %>" # inline ERB (avoid)
render nothing: true, status: :ok # empty body (deprecated — use head)
# head — response with no body
head :no_content # 204
head :created, location: post_url(@post)
# Render with status (Turbo requires it for error responses)
render :new, status: :unprocessable_entity # 422 — required for Turbo
render :edit, status: :unprocessable_entity
For Turbo/Hotwire: Failed form submissions need status: :unprocessable_entity (422) — without it, Turbo ignores the response and the user sees no error feedback.
respond_to for Multiple Formats
def show
@post = Post.find(params.expect(:id))
respond_to do |format|
format.html # renders show.html.erb
format.json { render json: @post }
format.pdf { send_data generate_pdf(@post), filename: "post.pdf" }
end
end
Step 6: Redirects
redirect_to @post # redirect to show
redirect_to posts_path # redirect to index
redirect_to root_path, notice: "Done!" # with flash notice
redirect_to root_path, alert: "Oops!" # with flash alert
redirect_to root_path, status: :see_other # 303 — use for DELETE actions
# Redirect back (with fallback)
redirect_back fallback_location: root_path
# ⚠️ After DELETE, use status: :see_other (303)
# This prevents browsers from replaying the DELETE on redirect
def destroy
@post.destroy!
redirect_to posts_path, notice: "Deleted.", status: :see_other
end
Step 7: Flash Messages
# Set in redirect
redirect_to @post, notice: "Saved!"
redirect_to @post, alert: "Problem!"
# Set manually (available on NEXT request)
flash[:notice] = "Saved!"
flash[:alert] = "Problem!"
# Set for CURRENT request (use when rendering, not redirecting)
flash.now[:error] = "Could not save."
render :new, status: :unprocessable_entity
# Carry flash through an extra redirect
flash.keep
redirect_to another_path
Rule of thumb: flash[...] before redirect_to. flash.now[...] before render.
Step 8: Sessions and Cookies
Sessions
# Store
session[:current_user_id] = user.id
# Read
session[:current_user_id]
# Delete
session.delete(:current_user_id)
# Nuke everything (do this on login to prevent session fixation)
reset_session
Cookies
# Basic (deleted when browser closes)
cookies[:theme] = "dark"
# With expiration
cookies[:theme] = { value: "dark", expires: 1.year }
# Permanent (20 years)
cookies.permanent[:locale] = "en"
# Signed (tamper-proof, but readable)
cookies.signed[:user_id] = current_user.id
cookies.signed[:user_id] # => 42
# Encrypted (tamper-proof AND unreadable)
cookies.encrypted[:token] = "secret"
cookies.encrypted[:token] # => "secret"
# Delete
cookies.delete(:theme)
Step 9: Error Handling with rescue_from
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found
respond_to do |format|
format.html { render file: Rails.root.join("public/404.html"), status: :not_found, layout: false }
format.json { render json: { error: "Not found" }, status: :not_found }
end
end
def unprocessable(exception)
respond_to do |format|
format.html { redirect_back fallback_location: root_path, alert: exception.message }
format.json { render json: { error: exception.message }, status: :unprocessable_entity }
end
end
def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end
Don't rescue Exception or StandardError — they catch things like SystemExit and SyntaxError, breaking Rails internals and hiding real bugs.
Step 10: CSRF Protection
Enabled by default. Key points:
# Default — raises exception on CSRF failure
protect_from_forgery with: :exception
# For API controllers — skip CSRF (use token auth instead)
class Api::BaseController < ActionController::API # no CSRF by default
end
Turbo and rails-ujs handle CSRF automatically. For custom fetch calls, read the token from the <meta name="csrf-token"> tag and send it as X-CSRF-Token header.
Step 11: HTTP Auth, Streaming, Request/Response
# Basic Auth — quick and dirty (admin panels, staging)
http_basic_authenticate_with name: "admin", password: ENV["ADMIN_PASSWORD"]
# Token Auth — for APIs
authenticate_or_request_with_http_token do |token, _options|
ActiveSupport::SecurityUtils.secure_compare(token, ENV["API_TOKEN"])
end
# Send generated data as download
send_data pdf_content, filename: "report.pdf", type: "application/pdf"
# Send existing file
send_file Rails.root.join("storage/report.pdf"), filename: "report.pdf"
# Live streaming — always close the stream
include ActionController::Live
response.headers["Content-Type"] = "text/event-stream"
response.stream.write "data: hello\n\n"
ensure
response.stream.close
# Useful request properties
request.remote_ip # client IP
request.get? / request.post? # method checks
request.headers["X-Custom"]
request.format # Mime::HTML, Mime::JSON, etc.
request.variant = :mobile # for device-specific views
Anti-Patterns to Avoid
- Fat controllers — Move business logic to models/services/form objects
permit!— Opens the door to mass assignment attacks; enumerate every permitted attributeparams[:foo]directly in model calls — Bypasses strong parameter filtering; go through the params method- Skipping CSRF broadly — Only skip for genuine API endpoints with token auth; CSRF protects against cross-site form submissions
rescue_from Exception— Catches syntax errors, SystemExit, and other things you don't want to swallow- Nested
if/elsechains in actions — Extract to service objects - Multiple renders/redirects — A controller action can only render or redirect once; use
and returnor early returns - Missing status on error renders — Turbo requires
status: :unprocessable_entity - Session for everything — Session is 4KB max with CookieStore; use the database for big data
- Redirect after DELETE without
:see_other— Can cause browsers to replay the DELETE
Quick Reference
See the references/ directory for detailed patterns and examples:
references/strong-params.md— Complete param permitting patterns (nested hashes, arrays, double array syntax)references/callbacks.md— All callback types, ordering rules, halting, skip patternsreferences/rendering.md— Full rendering options, redirects, flash messages, respond_to, variantsreferences/sessions-and-cookies.md— Session stores, cookie jar types, configurationreferences/security.md— rescue_from, CSRF, HTTP auth, CSP, log filtering, Force SSL, browser version controlreferences/streaming.md— send_data/send_file, SSE streaming, request/response objects, API controllers, health checks
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/action-controller">View action-controller on skillZs</a>