vapid
VAPID - Crystal Web Push Library
A Crystal shard for VAPID (Voluntary Application Server Identification) protocol implementation. Generate ES256 keypairs, create JWT tokens, and send authenticated Web Push notifications following RFC 8292.
Features
- ES256 Cryptographic Keys: Generate and manage ECDSA P-256 key pairs for VAPID authentication
- JWT Token Creation: Automatic JWT token generation and signing with ES256 algorithm
- VAPID Header Generation: RFC 8292-compliant Authorization and Crypto-Key headers
- Web Push Client: Send push notifications to RFC 8030 compliant push services (FCM, Mozilla AutoPush, etc.)
- Type Safety: Full Crystal type system support with compile-time type checking
- Comprehensive Error Handling: Specific error types for different failure scenarios
- Extensible Design: Clean, modular architecture for easy customization
Installation
Add this to your application's shard.yml:
dependencies:
vapid:
github: russ/vapid-crystal
version: ~> 0.1.0
Then run:
shards install
Quick Start
require "vapid"
# Generate a new ES256 key pair
key_pair = Vapid::KeyPair.generate
# Save your private key securely for reuse
File.write("private_key.pem", key_pair.private_key_pem)
# Send a push notification
client = Vapid::Client.new
response = client.send_notification(
endpoint: "https://fcm.googleapis.com/fcm/send/subscription-id",
subject: "mailto:admin@example.com",
private_key: key_pair,
payload: {
"title" => "Hello from VAPID!",
"body" => "Your first push notification"
}.to_json,
ttl: 3600
)
puts "Notification sent! Status: #{response.status_code}"
Usage
Key Pair Generation
# Generate a new P-256 (prime256v1/secp256r1) key pair
key_pair = Vapid::KeyPair.generate
# Export public key in URL-safe Base64 format (for browser subscription)
public_key = key_pair.public_key_base64
# => "BKj3vCS4mC3pP6..."
# Export private key in PEM format (for secure storage)
private_key_pem = key_pair.private_key_pem
File.write("vapid_private.pem", private_key_pem)
# Load existing key from PEM file
existing_key = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))
JWT Token Creation
key_pair = Vapid::KeyPair.generate
# Create a JWT token with required VAPID claims
token = Vapid::JWT.encode(
audience: "https://fcm.googleapis.com", # Push service origin
subject: "mailto:admin@example.com", # Your contact information
private_key: key_pair,
expiration: 12.hours # Token validity (max 24 hours)
)
# The token includes:
# - aud: Audience (push service origin)
# - exp: Expiration time
# - sub: Subject (contact info)
VAPID Headers
key_pair = Vapid::KeyPair.generate
# Create VAPID authentication headers
headers = Vapid::RequestHeaders.new(
audience: "https://fcm.googleapis.com",
subject: "mailto:admin@example.com",
private_key: key_pair,
expiration: 12.hours # Optional, defaults to 12 hours
)
# Get headers as a Hash
http_headers = headers.to_h
# => {
# "Authorization" => "vapid t=eyJ0eXAi..., k=BKj3vCS4mC3pP6...",
# "Crypto-Key" => "p256ecdsa=BKj3vCS4mC3pP6..."
# }
# Or as HTTP::Headers object
http_headers = headers.to_http_headers
Sending Push Notifications
client = Vapid::Client.new(timeout: 30.seconds)
key_pair = Vapid::KeyPair.from_pem(File.read("private_key.pem"))
# Send notification with payload
response = client.send_notification(
endpoint: "https://fcm.googleapis.com/fcm/send/abc123...",
subject: "mailto:admin@example.com",
private_key: key_pair,
payload: {
"notification" => {
"title" => "New Message",
"body" => "You have a new message!",
"icon" => "/icon.png"
}
}.to_json,
ttl: 3600, # Time to live in seconds
urgency: "high", # very-low, low, normal, or high
topic: "updates" # Optional topic for message replacement
)
# Send trigger notification (no payload)
response = client.send_notification(
endpoint: subscription_endpoint,
subject: "mailto:admin@example.com",
private_key: key_pair,
ttl: 86400
)
# Custom audience (usually auto-extracted from endpoint)
response = client.send_notification(
endpoint: subscription_endpoint,
audience: "https://custom.push.service",
subject: "mailto:admin@example.com",
private_key: key_pair
)
Error Handling
begin
client.send_notification(
endpoint: endpoint,
subject: "mailto:admin@example.com",
private_key: key_pair
)
rescue ex : Vapid::PushError
puts "Push failed: #{ex.message}"
puts "Status code: #{ex.status_code}"
puts "Response: #{ex.response_body}"
rescue ex : Vapid::ValidationError
puts "Invalid parameters: #{ex.message}"
rescue ex : Vapid::JWTError
puts "JWT error: #{ex.message}"
rescue ex : Vapid::CryptoError
puts "Cryptography error: #{ex.message}"
end
API Documentation
Vapid::KeyPair
Manages ES256 (ECDSA P-256) cryptographic key pairs.
Class Methods:
.generate : KeyPair- Generate a new P-256 key pair.from_pem(pem : String) : KeyPair- Load from PEM-encoded private key.from_der(der : Bytes) : KeyPair- Load from DER-encoded private key
Instance Methods:
#public_key_bytes : Bytes- Get raw public key (65 bytes, uncompressed)#public_key_base64 : String- Get public key as URL-safe base64#private_key_pem : String- Export private key as PEM#private_key_der : Bytes- Export private key as DER#to_openssl : OpenSSL::PKey::EC- Get underlying OpenSSL key
Vapid::JWT
Creates and verifies JWT tokens for VAPID authentication.
Module Methods:
-
.encode(audience, subject, private_key, expiration = 12.hours, extra_claims = nil) : String- Create and sign a VAPID JWT token
- audience: Push service origin (e.g., "https://fcm.googleapis.com")
- subject: Contact info as
mailto:orhttps:URL - private_key: KeyPair for signing
- expiration: Token validity period (max 24 hours)
- extra_claims: Optional additional JWT claims
-
.decode(token : String, public_key : KeyPair) : JSON::Any- Decode and verify a JWT token (for testing)
Vapid::RequestHeaders
Generates VAPID authentication headers for push requests.
Constructor:
RequestHeaders.new(
audience : String,
subject : String,
private_key : KeyPair,
expiration : Time::Span = 12.hours
)
Instance Methods:
#authorization_header : String- Get Authorization header value#crypto_key_header : String- Get Crypto-Key header value#to_h : Hash(String, String)- Get headers as Hash#to_http_headers : HTTP::Headers- Get headers as HTTP::Headers
Vapid::Client
HTTP client for sending VAPID-authenticated push notifications.
Constructor:
Client.new(timeout : Time::Span = 30.seconds)
Instance Methods:
-
#send_notification- Send a push notification- endpoint: Push subscription endpoint URL
- audience: Push service origin (auto-extracted if not provided)
- subject: Contact information
- private_key: KeyPair for authentication
- payload: Notification payload (JSON string or nil)
- ttl: Time to live in seconds (default: 28 days)
- urgency: Urgency level: very-low, low, normal, high (default: normal)
- topic: Topic for message replacement (optional)
- headers: Pre-built RequestHeaders (alternative to private_key/subject)
-
#extract_audience(endpoint : String) : String- Extract origin from endpoint URL
Error Classes
Vapid::Error- Base error classVapid::CryptoError- Cryptographic operation failuresVapid::JWTError- JWT encoding/decoding failuresVapid::PushError- HTTP push request failures (includes status_code and response_body)Vapid::ValidationError- Invalid parameter errors
VAPID Specification Compliance
This library implements RFC 8292 - Voluntary Application Server Identification (VAPID) for Web Push.
Key Requirements:
- ✅ ES256 (ECDSA P-256 + SHA-256) signatures
- ✅ JWT tokens with required claims (aud, exp, sub)
- ✅ Maximum 24-hour token expiration
- ✅ URL-safe base64 encoding without padding
- ✅ RFC 8292 compliant Authorization header format
- ✅ Backward-compatible Crypto-Key header
Supported Push Services:
- Firebase Cloud Messaging (FCM)
- Mozilla AutoPush
- Any RFC 8030 compliant push service
Development
# Install dependencies
shards install
# Run tests
crystal spec
# Run specific test file
crystal spec spec/vapid/key_pair_spec.cr
# Format code
crystal tool format
# Build documentation
crystal docs
# Check for issues
crystal tool ameba # If you have ameba installed
Testing
The project includes comprehensive specs testing:
- ✅ ES256 key pair generation and serialization
- ✅ JWT token creation and verification
- ✅ VAPID header generation
- ✅ Push notification sending (with WebMock)
- ✅ Error handling and edge cases
- ✅ RFC 8292 compliance
Run tests:
crystal spec
Security Considerations
- Private Key Storage: Always store private keys securely (encrypted, environment variables, secret managers)
- Token Expiration: Use short-lived tokens (recommended: 1-12 hours)
- HTTPS Only: Only use HTTPS endpoints for production
- Subject Validation: Ensure subject contains valid contact information
- Audience Matching: Verify audience matches the push service origin
Performance
- Key Generation: ~10-50ms per key pair (one-time operation)
- JWT Signing: <1ms per token
- Push Request: Depends on network and push service latency
Known Limitations
- Requires OpenSSL 1.1.1+ or 3.x
- Current implementation uses Crystal's standard OpenSSL bindings
- Some advanced OpenSSL features may require additional dependencies
Examples
Complete Push Notification Flow
require "vapid"
# 1. Generate and save key pair (one-time setup)
key_pair = Vapid::KeyPair.generate
File.write("vapid_private.pem", key_pair.private_key_pem)
puts "Application Server Public Key: #{key_pair.public_key_base64}"
# 2. User subscribes in browser with the public key
# (This happens in your JavaScript)
# 3. Send notification when needed
key_pair = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))
client = Vapid::Client.new
# Get subscription from your database
subscription_endpoint = get_user_subscription_endpoint(user_id)
begin
response = client.send_notification(
endpoint: subscription_endpoint,
subject: "mailto:support@myapp.com",
private_key: key_pair,
payload: {
"notification" => {
"title" => "Welcome!",
"body" => "Thanks for subscribing",
"data" => {"url" => "/dashboard"}
}
}.to_json,
ttl: 86400,
urgency: "normal"
)
puts "✓ Notification sent successfully!"
rescue ex : Vapid::PushError
case ex.status_code
when 404, 410
# Subscription expired or invalid - remove from database
remove_subscription(user_id)
when 401, 403
# Authentication error - check your keys
puts "Auth error: #{ex.message}"
else
puts "Push failed: #{ex.message}"
end
end
Batch Notifications
client = Vapid::Client.new
key_pair = Vapid::KeyPair.from_pem(File.read("vapid_private.pem"))
subscriptions.each do |subscription|
spawn do
begin
client.send_notification(
endpoint: subscription.endpoint,
subject: "mailto:admin@example.com",
private_key: key_pair,
payload: notification_payload,
ttl: 3600
)
rescue ex
log_error(subscription.id, ex)
end
end
end
# Wait for all fibers to complete
Fiber.yield
Resources
- VAPID Specification (RFC 8292)
- Web Push Protocol (RFC 8030)
- Web Push API (MDN)
- Crystal Language Documentation
Contributing
- Fork it (https://github.com/your-github-user/vapid-crystal/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Write tests for your changes
- Ensure all tests pass (
crystal spec) - Format your code (
crystal tool format) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT License - see LICENSE file for details.
Contributors
- Russ Smith - creator and maintainer
Technical Notes
Dependencies
This library requires openssl_ext version 2.8.2 or later, which includes a critical fix for EC_GROUP preservation when loading elliptic curve keys from PEM format. This ensures reliable operation across all platforms including Docker, Alpine Linux, and musl-based systems.
Platform Support
✅ All Platforms Supported:
- Linux (glibc and musl)
- Docker containers (Ubuntu, Alpine)
- macOS
- OpenSSL 1.1.x and 3.x
Acknowledgments
- Built on Crystal's excellent OpenSSL bindings
- Uses crystal-community/jwt for JWT support
- Uses spider-gazelle/openssl_ext for extended OpenSSL functionality
- Implements VAPID specification from IETF RFC 8292
Made with ❤️ using Crystal
vapid
- 0
- 0
- 0
- 0
- 3
- 5 days ago
- November 20, 2025
MIT License
Tue, 25 Nov 2025 15:30:53 GMT