# 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: 1. **VAPID key generation and configuration** 2. **Database model** for storing push subscriptions 3. **API endpoints** for subscription management 4. **Service layer** for sending notifications 5. **Background job** for async processing 6. **Client-side JavaScript** for subscription handling 7. **Service worker** for receiving notifications 8. **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`: ```ruby # 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: ```ruby # 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: ```ruby # 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: ```ruby 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 ```ruby # 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: ```ruby # app/models/user.rb has_many :push_subscriptions, dependent: :destroy ``` ### 6. API Controller Create endpoints for managing subscriptions: ```ruby # 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: ```ruby # 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: ```ruby # 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: ```ruby # 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: ```javascript // 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: ```javascript // 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: ```erb <% if user_signed_in? && defined?(Vapid) && Vapid.configured? %>
<% end %> ``` ### 13. Routes Add routes for subscriptions and enable PWA routes: ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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 ```ruby # 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 1. **User signs in** (email, phone OTP, or OAuth) 2. **Warden hook** detects `event: :authentication` and enqueues `LoginAlertPushJob` 3. **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` 4. **Job runs** and `WebPushService` sends login-alert push to all user subscriptions 5. **Service worker** receives push and shows notification 6. **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?` returns `true` ### 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!** 🚀