Paystack Webhooks and CSRF Protection in Rails
Rails protects all POST requests with CSRF token verification by default. Paystack webhooks are server-to-server POST requests that do not carry a CSRF token. Rails rejects them with a 422 Unprocessable Entity or ActionController::InvalidAuthenticityToken error. The fix is to skip verify_authenticity_token in your webhook controller and rely on Paystack HMAC signature verification instead.
The Problem: Rails Blocks Paystack Webhooks
You deploy your Rails app, configure the webhook URL in the Paystack Dashboard, trigger a test payment, and nothing happens. Your logs show one of these:
ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):
# or
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity
Rails includes CSRF (Cross-Site Request Forgery) protection by default. Every POST request must include a valid CSRF token. This token is generated by Rails, embedded in your HTML forms, and verified on every form submission. It prevents malicious websites from tricking your users' browsers into making unwanted requests to your app.
Paystack webhooks are not form submissions. They are server-to-server POST requests. Paystack's servers do not have access to your Rails CSRF tokens. They cannot include one. So Rails rejects every webhook with a 422 or raises an exception before your handler code ever runs.
Paystack sees the 422 as a failed delivery and retries. Every retry also gets rejected. Eventually, Paystack gives up. You never receive any webhook events.
This article is part of the Paystack webhooks engineering guide. For the same issue in Django and Laravel, see the Django guide and the Laravel guide.
The Fix: skip_before_action for Webhooks
Tell Rails to skip CSRF verification for your webhook action. Create a dedicated controller for Paystack webhooks:
# app/controllers/webhooks/paystack_controller.rb
module Webhooks
class PaystackController < ApplicationController
# Skip CSRF verification for webhook actions.
# Paystack requests are server-to-server and do not carry CSRF tokens.
# We verify authenticity using the HMAC signature instead.
skip_before_action :verify_authenticity_token, only: [:receive]
def receive
# Read the raw body BEFORE Rails parses it
raw_body = request.raw_post
signature = request.headers['X-Paystack-Signature']
# Verify the HMAC signature
unless valid_signature?(raw_body, signature)
head :bad_request
return
end
# Return 200 immediately
head :ok
# Queue the event for background processing
event = JSON.parse(raw_body)
PaystackWebhookJob.perform_later(
event_type: event['event'],
payload: event['data']
)
end
private
def valid_signature?(body, signature)
return false if signature.blank?
secret = Rails.application.credentials.paystack_secret_key
computed = OpenSSL::HMAC.hexdigest('sha512', secret, body)
ActiveSupport::SecurityUtils.secure_compare(computed, signature)
end
end
end
Add the route:
# config/routes.rb
Rails.application.routes.draw do
namespace :webhooks do
post 'paystack', to: 'paystack#receive'
end
# Your other routes...
end
The webhook URL you enter in the Paystack Dashboard is https://yourapp.com/webhooks/paystack.
Key points about the fix:
skip_before_action :verify_authenticity_tokenonly applies to the:receiveaction. All other actions in this controller (if you add any) still have CSRF protection. All other controllers in your app are unaffected.- The
only: [:receive]clause is explicit. If you later add aretryorstatusaction to this controller, those actions will still have CSRF verification unless you add them to theonlyarray. - We use a dedicated
Webhooksnamespace. This keeps webhook controllers separate from user-facing controllers and makes the security boundary clear.
Signature Verification in Ruby
When you skip CSRF protection, you must replace it with something stronger for server-to-server authentication. Paystack's HMAC SHA512 signature is that replacement.
# Detailed signature verification
def valid_signature?(raw_body, signature)
# Guard against missing signature
return false if signature.blank?
# Get the secret key from credentials
# Store this in config/credentials.yml.enc:
# paystack_secret_key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
secret = Rails.application.credentials.paystack_secret_key
if secret.blank?
Rails.logger.error('PAYSTACK_SECRET_KEY is not configured')
return false
end
# Compute HMAC SHA512 of the raw body
computed = OpenSSL::HMAC.hexdigest('sha512', secret, raw_body)
# Use secure_compare to prevent timing attacks.
# A regular == comparison leaks information about how many
# characters matched, which an attacker could use to
# forge a signature one character at a time.
ActiveSupport::SecurityUtils.secure_compare(computed, signature)
end
Three details worth noting:
Use request.raw_post, not params. Rails parses the JSON body into params automatically. If you call params.to_json to reconstruct the body, the JSON might have different whitespace, key ordering, or encoding than what Paystack sent. The HMAC will not match. Always use the raw bytes. See the raw body problem for more on this.
Use secure_compare, not ==. The == operator short-circuits: it returns false as soon as it finds the first non-matching character. An attacker who can measure response times could theoretically determine how many characters of the signature they got right. secure_compare always takes the same amount of time regardless of where the mismatch occurs. This is called a constant-time comparison.
Store the secret key in Rails credentials. Do not put it in plain text in application.rb or a YAML file. Use rails credentials:edit to add it to the encrypted credentials store. In production, set the RAILS_MASTER_KEY environment variable so Rails can decrypt credentials.
Background Processing with Active Job
After verifying the signature and returning 200, process the event in the background. Rails has Active Job built in, and it works with several queue backends (Sidekiq, GoodJob, Solid Queue, Delayed Job).
# app/jobs/paystack_webhook_job.rb
class PaystackWebhookJob < ApplicationJob
queue_as :webhooks
# Retry on transient failures
retry_on StandardError, wait: :polynomially_longer, attempts: 5
def perform(event_type:, payload:)
case event_type
when 'charge.success'
handle_charge_success(payload)
when 'transfer.success'
handle_transfer_success(payload)
when 'transfer.failed'
handle_transfer_failed(payload)
when 'refund.processed'
handle_refund_processed(payload)
else
Rails.logger.info("Unhandled Paystack event: #{event_type}")
end
end
private
def handle_charge_success(payload)
reference = payload['reference']
amount = payload['amount']
ActiveRecord::Base.transaction do
# Idempotency check
return if ProcessedEvent.exists?(idempotency_key: reference)
ProcessedEvent.create!(
idempotency_key: reference,
event_type: 'charge.success',
payload: payload
)
order = Order.find_by(payment_reference: reference)
return unless order
order.update!(
status: 'paid',
paid_at: Time.current,
amount_paid: amount
)
end
end
def handle_transfer_success(payload)
reference = payload['reference']
key = reference + ':transfer.success'
ActiveRecord::Base.transaction do
return if ProcessedEvent.exists?(idempotency_key: key)
ProcessedEvent.create!(
idempotency_key: key,
event_type: 'transfer.success',
payload: payload
)
payout = Payout.find_by(reference: reference)
return unless payout
payout.update!(status: 'success', completed_at: Time.current)
end
end
def handle_transfer_failed(payload)
reference = payload['reference']
key = reference + ':transfer.failed'
ActiveRecord::Base.transaction do
return if ProcessedEvent.exists?(idempotency_key: key)
ProcessedEvent.create!(
idempotency_key: key,
event_type: 'transfer.failed',
payload: payload
)
payout = Payout.find_by(reference: reference)
return unless payout
payout.update!(
status: 'failed',
failure_reason: payload['reason'] || 'Unknown'
)
end
end
def handle_refund_processed(payload)
refund_id = payload['id'].to_s
key = 'refund:' + refund_id
ActiveRecord::Base.transaction do
return if ProcessedEvent.exists?(idempotency_key: key)
ProcessedEvent.create!(
idempotency_key: key,
event_type: 'refund.processed',
payload: payload
)
transaction_ref = payload.dig('transaction', 'reference')
order = Order.find_by(payment_reference: transaction_ref)
return unless order
order.update!(status: 'refunded', refunded_at: Time.current)
end
end
end
The retry_on declaration tells Active Job to retry the job with polynomially increasing delays if any StandardError occurs. After 5 failed attempts, the job goes to the dead letter queue. This is your safety net for transient database errors or connectivity issues.
For more on queueing patterns, see BullMQ (Node.js), Celery (Python), and Laravel Queues.
The ProcessedEvent Model and Migration
Create the ProcessedEvent model for idempotency:
rails generate model ProcessedEvent idempotency_key:string:uniq event_type:string payload:jsonb
Edit the migration to add the UNIQUE constraint and an index:
# db/migrate/xxxxxx_create_processed_events.rb
class CreateProcessedEvents < ActiveRecord::Migration[7.1]
def change
create_table :processed_events do |t|
t.string :idempotency_key, null: false
t.string :event_type, null: false
t.jsonb :payload, default: {}
t.timestamps
end
add_index :processed_events, :idempotency_key, unique: true
end
end
Run the migration:
rails db:migrate
The UNIQUE index on idempotency_key is the real safeguard. Even if two webhook deliveries arrive simultaneously and both pass the exists? check (a rare race condition), the database will reject the second create! call with an ActiveRecord::RecordNotUnique error. The transaction rolls back, and no double-processing occurs.
For the complete idempotency pattern, see idempotent webhook handlers.
Rails API-Only Applications
If your Rails app is API-only (config.api_only = true in application.rb), the CSRF middleware is not loaded by default. Controllers inherit from ActionController::API instead of ActionController::Base, and verify_authenticity_token is not included.
In this case, you do not need the skip_before_action line. Your webhook controller just needs signature verification:
# app/controllers/webhooks/paystack_controller.rb (API-only app)
module Webhooks
class PaystackController < ApplicationController
# No skip_before_action needed in API-only mode.
# CSRF middleware is not loaded.
def receive
raw_body = request.raw_post
signature = request.headers['X-Paystack-Signature']
unless valid_signature?(raw_body, signature)
head :bad_request
return
end
head :ok
event = JSON.parse(raw_body)
PaystackWebhookJob.perform_later(
event_type: event['event'],
payload: event['data']
)
end
private
def valid_signature?(body, signature)
return false if signature.blank?
secret = Rails.application.credentials.paystack_secret_key
computed = OpenSSL::HMAC.hexdigest('sha512', secret, body)
ActiveSupport::SecurityUtils.secure_compare(computed, signature)
end
end
end
However, if any controller in your API-only app inherits from ActionController::Base (for a mixed app that also serves HTML), or if you have manually included the CSRF module, you will still need the skip_before_action line. Check your ApplicationController to confirm which base class it uses.
Testing the Webhook Controller
Write controller tests that verify CSRF is properly skipped and signature verification works:
# test/controllers/webhooks/paystack_controller_test.rb
# or spec/controllers/webhooks/paystack_controller_spec.rb (RSpec)
require 'test_helper'
class Webhooks::PaystackControllerTest < ActionDispatch::IntegrationTest
setup do
@secret = 'sk_test_secret_for_testing'
Rails.application.credentials.stubs(:paystack_secret_key).returns(@secret)
end
test 'returns 400 for missing signature' do
post webhooks_paystack_url,
params: '{"event":"charge.success"}',
headers: { 'Content-Type' => 'application/json' }
assert_response :bad_request
end
test 'returns 400 for invalid signature' do
body = '{"event":"charge.success","data":{"reference":"ref-001"}}'
post webhooks_paystack_url,
params: body,
headers: {
'Content-Type' => 'application/json',
'X-Paystack-Signature' => 'invalid_signature'
}
assert_response :bad_request
end
test 'returns 200 for valid signature and enqueues job' do
body = '{"event":"charge.success","data":{"reference":"ref-001","amount":50000}}'
signature = OpenSSL::HMAC.hexdigest('sha512', @secret, body)
assert_enqueued_with(job: PaystackWebhookJob) do
post webhooks_paystack_url,
params: body,
headers: {
'Content-Type' => 'application/json',
'X-Paystack-Signature' => signature
}
end
assert_response :ok
end
test 'does not require CSRF token' do
# This test verifies that the webhook endpoint works
# without a CSRF token, unlike regular POST endpoints
body = '{"event":"charge.success","data":{"reference":"ref-001"}}'
signature = OpenSSL::HMAC.hexdigest('sha512', @secret, body)
# No CSRF token in the request
post webhooks_paystack_url,
params: body,
headers: {
'Content-Type' => 'application/json',
'X-Paystack-Signature' => signature
}
# Should succeed without CSRF token
assert_response :ok
end
end
These tests confirm four things:
- Missing signatures are rejected.
- Invalid signatures are rejected.
- Valid signatures are accepted and the job is enqueued.
- CSRF tokens are not required (the whole point of this article).
For more on testing webhooks without a public URL, see testing in CI without a public URL and simulating webhook events.
Common Mistakes
Disabling CSRF globally. Do not set config.action_controller.allow_forgery_protection = false or skip CSRF in your ApplicationController. This removes CSRF protection from your entire app, leaving user-facing forms vulnerable to CSRF attacks. Only skip it for the specific webhook action.
Using protect_from_forgery with: :null_session instead of skipping. This does not raise an error, but it resets the session. If your webhook handler accidentally touches the session (rare but possible with before_action callbacks from ApplicationController), it will behave unexpectedly. The clean approach is skip_before_action.
Verifying the signature after parsing params. If you do params.to_json to get the body for signature verification, the JSON string might differ from what Paystack sent. Rails may reorder keys, change number formatting, or strip whitespace. Always use request.raw_post.
Forgetting to return after head :bad_request. In Rails, head sets the response but does not stop execution. Without an explicit return, the rest of the action runs, potentially processing an unverified event. Always return after sending the response.
Not using a dedicated controller. Putting the webhook action in an existing controller (like PaymentsController) that has other user-facing actions is risky. If you skip CSRF for the webhook action, you might accidentally skip it for other actions in the future. A dedicated Webhooks::PaystackController makes the security boundary clear.
Key Takeaways
- ✓Rails CSRF protection blocks Paystack webhooks because they lack an authenticity token. The error is ActionController::InvalidAuthenticityToken or a 422 response.
- ✓Skip CSRF verification for the webhook action with skip_before_action :verify_authenticity_token, only: [:receive] in your webhook controller.
- ✓Create a dedicated controller for webhooks. Do not mix webhook actions with regular user-facing actions that need CSRF protection.
- ✓Replace CSRF protection with Paystack HMAC SHA512 signature verification. This is stronger than CSRF tokens for server-to-server communication.
- ✓Read the raw request body with request.raw_post before Rails parses it. Use this raw body for signature computation.
- ✓Return head :ok (200) immediately. Queue heavy work with Active Job or Sidekiq.
Frequently Asked Questions
- Why does Rails block Paystack webhook requests?
- Rails includes CSRF protection by default. Every POST request must include a valid authenticity token. Paystack webhook requests are server-to-server and do not carry this token. Rails rejects them with a 422 Unprocessable Entity error or raises ActionController::InvalidAuthenticityToken.
- Is it safe to skip CSRF protection for webhook endpoints?
- Yes, because you replace it with something stronger: Paystack HMAC SHA512 signature verification. CSRF tokens protect against browser-based attacks where a malicious site tricks a user logged-in browser into making requests. Webhooks are server-to-server; there is no browser involved. HMAC signature verification authenticates that the request came from Paystack.
- How do I store the Paystack secret key in Rails?
- Use Rails encrypted credentials. Run rails credentials:edit and add paystack_secret_key: sk_live_your_key. Access it in code with Rails.application.credentials.paystack_secret_key. In production, set the RAILS_MASTER_KEY environment variable so Rails can decrypt the credentials file.
- Do I need to skip CSRF in a Rails API-only app?
- No. Rails API-only apps (config.api_only = true) use ActionController::API, which does not include CSRF middleware. The skip_before_action line is unnecessary. You still need signature verification.
- Should I use protect_from_forgery with: :null_session for webhooks?
- No. While null_session prevents the 422 error, it silently resets the session instead of raising an error. The cleaner approach is skip_before_action :verify_authenticity_token for the specific webhook action. This makes the intent explicit: this endpoint intentionally does not use CSRF protection.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon