Shipping web push with VAPID
Implementing Web Push Notifications with VAPID Keys for Login Alerts in Rails
Introduction
In today’s security-conscious world, users want to be notified immediately when someone signs into their account. Email notifications are great, but they can be delayed or missed. Web Push notifications provide instant, real-time alerts directly to the user’s browser—even when they’re not actively on your site.
In this blog post, I’ll walk you through implementing Web Push notifications with VAPID keys in a Rails application, specifically for login alerts. We’ll cover everything from understanding VAPID to writing comprehensive RSpec tests.
What are VAPID Keys?
VAPID (Voluntary Application Server Identification) is a protocol that allows your server to identify itself to push services (like Chrome, Firefox, and Edge) when sending Web Push notifications. It eliminates the need for third-party services like Google Cloud Messaging (GCM) and provides a more direct, secure approach.
Key Components
- Public Key: Shared with the browser when creating a push subscription (safe to expose)
- Private Key: Kept secret on your server, used to sign requests to push services
- Subject: A contact URI (mailto: or https:) identifying your application server
Why VAPID?
- No third-party dependencies: Direct communication with browser push services
- Security: Cryptographic authentication ensures only your server can send notifications
- Standard protocol: Works across Chrome, Firefox, Edge, and other modern browsers
- Free: No service fees or quotas
When Do You Need VAPID?
You need VAPID keys only if you want to send Web Push notifications (browser/PWA push). You don’t need them for:
- Email notifications
- SMS notifications
- Native mobile push (FCM/APNs use different credentials)
- Third-party push services that abstract VAPID away
Use Cases for Web Push in E-commerce
- Order lifecycle: Order confirmed, shipped, out for delivery, delivered
- Cart/checkout: Abandoned cart reminders, payment failures, price drops
- Inventory alerts: Back-in-stock notifications, low-stock urgency
- Marketing: Flash sales, personalized offers (with proper consent)
- Account/security: Login alerts, suspicious activity notifications
In our implementation, we’ll focus on login alerts—notifying users immediately when someone signs into their account.
Implementation Overview
Our implementation consists of:
- VAPID key generation and configuration
- Database model for storing push subscriptions
- API endpoints for subscription management
- Service layer for sending notifications
- Background job for async processing
- Client-side JavaScript for subscription handling
- Service worker for receiving notifications
- Warden hook to trigger alerts on login
Step-by-Step Implementation
1. Add the Web Push Gem
First, add the webpush gem to your Gemfile:
# Web Push (VAPID) for browser login-alert notifications
gem "webpush"
Run bundle install.
2. Generate VAPID Keys
We’ll create a Rake task to generate VAPID keys using OpenSSL 3-compatible code:
# lib/tasks/webpush.rake
namespace :webpush do
desc "Generate VAPID key pair for Web Push"
task vapid_keys: :environment do
ec = OpenSSL::PKey::EC.generate("prime256v1")
pub_bin = ec.public_key.to_bn.to_s(2)
priv_bin = ec.private_key.to_s(2)
pub_b64 = Base64.urlsafe_encode64(pub_bin)
priv_b64 = Base64.urlsafe_encode64(priv_bin)
puts "Add these to Rails credentials (rails credentials:edit) under web_push:"
puts " vapid_public_key: #{pub_b64}"
puts " vapid_private_key: #{priv_b64}"
# ... output instructions
end
end
Run bin/rails webpush:vapid_keys and store the keys in your Rails credentials or environment variables.
3. VAPID Configuration Module
Create a module to centralize VAPID configuration:
# config/initializers/vapid.rb
module Vapid
def self.public_key
ENV["VAPID_PUBLIC_KEY"].presence ||
Rails.application.credentials.dig(:web_push, :vapid_public_key).presence
end
def self.private_key
ENV["VAPID_PRIVATE_KEY"].presence ||
Rails.application.credentials.dig(:web_push, :vapid_private_key).presence
end
def self.subject
ENV["VAPID_SUBJECT"].presence || "mailto:support@example.com"
end
def self.configured?
public_key.present? && private_key.present?
end
end
This allows flexible configuration via ENV (for production) or credentials (for development).
4. Database Migration
Create a migration for push subscriptions:
class CreatePushSubscriptions < ActiveRecord::Migration[8.0]
def change
create_table :push_subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.string :endpoint, null: false
t.string :p256dh_key, null: false
t.string :auth_key, null: false
t.string :user_agent
t.timestamps
end
add_index :push_subscriptions, [:user_id, :endpoint],
unique: true,
name: "index_push_subscriptions_on_user_id_and_endpoint"
end
end
The unique index ensures one subscription per endpoint per user.
5. PushSubscription Model
# app/models/push_subscription.rb
class PushSubscription < ApplicationRecord
belongs_to :user
validates :endpoint, presence: true, uniqueness: { scope: :user_id }
validates :p256dh_key, presence: true
validates :auth_key, presence: true
end
Add the association to your User model:
# app/models/user.rb
has_many :push_subscriptions, dependent: :destroy
6. API Controller
Create endpoints for managing subscriptions:
# app/controllers/push_subscriptions_controller.rb
class PushSubscriptionsController < ApplicationController
before_action :authenticate_user!
# POST /push_subscriptions
def create
sub = subscription_params
unless sub[:endpoint].present? &&
sub.dig(:keys, :p256dh).present? &&
sub.dig(:keys, :auth).present?
return render json: { error: "Missing subscription endpoint or keys" },
status: :unprocessable_entity
end
record = current_user.push_subscriptions
.find_or_initialize_by(endpoint: sub[:endpoint])
record.assign_attributes(
p256dh_key: sub[:keys][:p256dh],
auth_key: sub[:keys][:auth],
user_agent: request.user_agent
)
if record.save
render json: { id: record.id }, status: :created
else
render json: { error: record.errors.full_messages.to_sentence },
status: :unprocessable_entity
end
end
# DELETE /push_subscriptions/:id
def destroy
record = current_user.push_subscriptions.find_by(id: params[:id])
if record
record.destroy
head :no_content
else
head :not_found
end
end
private
def subscription_params
p = params.require(:subscription).permit(:endpoint, keys: {})
keys = (p[:keys] || p["keys"] || {}).slice("p256dh", "auth", :p256dh, :auth)
keys = keys.transform_keys(&:to_sym) if keys.present?
{ endpoint: p[:endpoint].presence || p["endpoint"], keys: keys }
end
end
7. Web Push Service
Create a service to handle sending notifications:
# app/services/web_push_service.rb
class WebPushService
def self.send_login_alert(user)
new(user: user).send_login_alert
end
def initialize(user:)
@user = user
end
def send_login_alert
return unless Vapid.configured?
return if @user.push_subscriptions.empty?
payload = {
title: "Login alert",
body: login_alert_body,
icon: icon_url,
data: { path: "/", type: "login_alert" }
}
@user.push_subscriptions.find_each do |sub|
send_to_subscription(sub, payload)
end
end
def send_to_subscription(subscription, payload)
Webpush.payload_send(
message: payload.to_json,
endpoint: subscription.endpoint,
p256dh: subscription.p256dh_key,
auth: subscription.auth_key,
vapid: vapid_options,
open_timeout: 5,
read_timeout: 5
)
rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription => e
Rails.logger.info "WebPush: removing invalid subscription #{subscription.id}: #{e.message}"
subscription.destroy
rescue StandardError => e
Rails.logger.warn "WebPush: send failed for subscription #{subscription.id}: #{e.message}"
end
private
def login_alert_body
at = Time.current.strftime("%b %-d, %Y at %-I:%M %p")
"You signed in to Ecommerce App on #{at}. If this wasn't you, secure your account."
end
def icon_url
base = Rails.application.config.asset_host.presence || ENV["APP_HOST"]
base ||= "http://localhost:3000" if Rails.env.development?
return nil if base.blank?
"#{base.to_s.chomp("/")}/icon.png"
end
def vapid_options
{
subject: Vapid.subject,
public_key: Vapid.public_key,
private_key: Vapid.private_key
}
end
end
8. Background Job
Create a job to send alerts asynchronously:
# app/jobs/login_alert_push_job.rb
class LoginAlertPushJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find_by(id: user_id)
return unless user
WebPushService.send_login_alert(user)
end
end
9. Warden Hook for Login Detection
Hook into Devise’s authentication to trigger the job:
# config/initializers/warden_hooks.rb
if defined?(Warden)
Warden::Manager.after_set_user do |user, auth, opts|
begin
# ... existing code ...
elsif user.present? && opts[:event] == :authentication &&
defined?(Vapid) && Vapid.configured?
LoginAlertPushJob.perform_later(user.id)
end
rescue => e
Rails.logger.debug "Warden after_set_user error: #{e.class}: #{e.message}"
end
end
end
The event: :authentication ensures we only trigger on fresh logins, not session restorations.
10. Client-Side JavaScript
Create a Stimulus controller to handle subscription:
// app/javascript/controllers/push_subscribe_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
vapidPublicKey: String,
pushSubscriptionsPath: String,
serviceWorkerPath: { type: String, default: "/service-worker" }
}
connect() {
if (!this.vapidPublicKeyValue || !this.pushSubscriptionsPathValue) return
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return
this.subscribe()
}
async subscribe() {
try {
const reg = await navigator.serviceWorker.register(
this.serviceWorkerPathValue,
{ scope: "/" }
)
await navigator.serviceWorker.ready
if (Notification.permission === "denied") return
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission()
if (permission !== "granted") return
}
const key = this.urlBase64ToUint8Array(this.vapidPublicKeyValue)
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key
})
await this.sendSubscription(sub)
} catch (e) {
console.warn("Push subscribe error:", e)
}
}
urlBase64ToUint8Array(base64) {
const padding = "=".repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/")
const raw = atob(b64)
const out = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i)
return out
}
async sendSubscription(subscription) {
const csrf = document.querySelector('meta[name="csrf-token"]')
?.getAttribute("content")
const res = await fetch(this.pushSubscriptionsPathValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": csrf || ""
},
body: JSON.stringify({ subscription: subscription.toJSON() }),
credentials: "same-origin"
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
console.warn("Push subscription save failed:", j.error || res.statusText)
}
}
}
11. Service Worker
Update your service worker to handle push events:
// app/views/pwa/service-worker.js
self.addEventListener("push", (event) => {
const promise = (async () => {
let title = "Ecommerce App"
let options = { icon: "/icon.png", data: { path: "/", type: "push" } }
if (event.data) {
try {
const payload = event.data.json()
title = payload.title || title
options = {
body: payload.body,
icon: payload.icon || "/icon.png",
tag: payload.data?.type || "push",
data: payload.data || options.data
}
} catch (_) {
// ignore
}
}
await self.registration.showNotification(title, options)
})()
event.waitUntil(promise)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const path = (event.notification.data && event.notification.data.path) || "/"
const open = () => {
if (clients.openWindow) return clients.openWindow(path)
}
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true })
.then((list) => {
for (let i = 0; i < list.length; i++) {
const c = list[i]
const u = new URL(c.url)
if (u.pathname === path && "focus" in c) return c.focus()
}
return open()
})
.catch(open)
)
})
12. Layout Integration
Add the controller to your layout when the user is signed in:
<!-- app/views/layouts/application.html.erb -->
<% if user_signed_in? && defined?(Vapid) && Vapid.configured? %>
<div data-controller="push-subscribe"
data-push-subscribe-vapid-public-key-value="<%= Vapid.public_key %>"
data-push-subscribe-push-subscriptions-path-value="<%= push_subscriptions_path %>"
class="visually-hidden" aria-hidden="true"></div>
<% end %>
13. Routes
Add routes for subscriptions and enable PWA routes:
# config/routes.rb
authenticate :user do
resources :push_subscriptions, only: [:create, :destroy]
end
# Enable PWA routes
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
Testing with RSpec
We wrote 53 comprehensive RSpec examples covering all aspects of the implementation:
Model Specs
# spec/models/push_subscription_spec.rb
RSpec.describe PushSubscription, type: :model do
it { is_expected.to belong_to(:user) }
it { is_expected.to validate_presence_of(:endpoint) }
it { is_expected.to validate_uniqueness_of(:endpoint).scoped_to(:user_id) }
end
Controller Specs
# spec/controllers/push_subscriptions_controller_spec.rb
RSpec.describe PushSubscriptionsController, type: :controller do
describe "POST #create" do
it "creates a push_subscription" do
post :create, params: valid_params, as: :json
expect(response).to have_http_status(:created)
expect(user.push_subscriptions.count).to eq(1)
end
end
end
Service Specs
# spec/services/web_push_service_spec.rb
RSpec.describe WebPushService do
it "sends login alert when VAPID configured" do
allow(Vapid).to receive(:configured?).and_return(true)
allow(Webpush).to receive(:payload_send)
described_class.send_login_alert(user)
expect(Webpush).to have_received(:payload_send)
end
end
Integration Specs
# spec/requests/login_alert_push_integration_spec.rb
RSpec.describe "Login alert Web Push integration" do
it "enqueues LoginAlertPushJob on successful sign-in" do
expect {
post user_session_path, params: { user: { email: user.email, password: "ValidPass123!" } }
}.to have_enqueued_job(LoginAlertPushJob).with(user.id)
end
end
How It Works: The Complete Flow
- User signs in (email, phone OTP, or OAuth)
- Warden hook detects
event: :authenticationand enqueuesLoginAlertPushJob - On page load, the push-subscribe controller:
- Registers the service worker
- Requests notification permission (automatic prompt)
- Subscribes to push with VAPID public key
- POSTs subscription to
/push_subscriptions
- Job runs and
WebPushServicesends login-alert push to all user subscriptions - Service worker receives push and shows notification
- User clicks notification → browser focuses/opens the app
Security Considerations
- VAPID private key: Never expose in client-side code or logs
- Subscription validation: Always validate subscription belongs to the authenticated user
- Rate limiting: Consider rate limiting push notifications to prevent abuse
- User consent: Only prompt for notifications when appropriate (e.g., after login)
- Invalid subscriptions: Automatically clean up expired/invalid subscriptions
Troubleshooting
Keys Not Working
- Ensure keys are base64url-encoded (no padding)
- Verify keys are stored correctly in credentials/ENV
- Check that
Vapid.configured?returnstrue
Notifications Not Appearing
- Verify service worker is registered (
navigator.serviceWorker.ready) - Check browser notification permissions
- Ensure subscription is saved to database
- Check browser console for errors
OpenSSL 3.0 Compatibility
The webpush gem has OpenSSL 3.0 compatibility issues. Our rake task uses OpenSSL directly to generate keys, avoiding the gem’s key generation.
Conclusion
Implementing Web Push notifications with VAPID keys provides a secure, direct way to send real-time notifications to users. Our implementation covers:
- ✅ VAPID key generation and configuration
- ✅ Subscription management API
- ✅ Service layer for sending notifications
- ✅ Background job processing
- ✅ Client-side subscription handling
- ✅ Service worker push handling
- ✅ Comprehensive RSpec test coverage (53 examples)
The login-alert feature is now fully functional and tested. Users will receive instant notifications whenever someone signs into their account, enhancing security and user experience.
Next Steps
- Extend to other use cases (order updates, cart reminders, etc.)
- Add notification preferences (users can opt-out of specific types)
- Implement notification history
- Add analytics for notification delivery and click-through rates
Happy coding! 🚀