Self-Hosted Push Notifications Specification
Part 7: Best Practices, Security & Optimization
Version: 1.0
Last Updated: October 2025
Prerequisites: Part 6: Monitoring, Debugging & Troubleshooting
Author: Bunty9
License: MIT (Free to use and adapt)
Table of Contents
- Security Best Practices
- VAPID Key Management
- Input Validation
- SQL Injection Prevention
- Authentication & Authorization
- Performance Optimization
- Code Organization
- Testing Strategies
- Documentation Standards
- Deployment Checklist
Security Best Practices
1. Secrets Management
❌ Bad Practice:
// Hardcoded secrets
const VAPIDPrivateKey = "BNw7fc1ayj3-Az-OJ8DrOj3EDAHCORwO_r3SsrpwkzQ"
✅ Good Practice:
// Load from environment
vapidPrivateKey := os.Getenv("VAPID_PRIVATE_KEY")
if vapidPrivateKey == "" {
log.Fatal("VAPID_PRIVATE_KEY is required")
}
// Or from secrets manager
func loadVAPIDKeys() (string, string, error) {
secretName := "vapid-keys"
region := "us-east-1"
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
})
if err != nil {
return "", "", err
}
svc := secretsmanager.New(sess)
input := &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
}
result, err := svc.GetSecretValue(input)
if err != nil {
return "", "", err
}
var secretData map[string]string
json.Unmarshal([]byte(*result.SecretString), &secretData)
return secretData["public_key"], secretData["private_key"], nil
}
2. Rate Limiting
Apply rate limiting at multiple levels:
// Per-user rate limiting
type UserRateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
}
// Global rate limiting
type GlobalRateLimiter struct {
limiter *rate.Limiter
}
// IP-based rate limiting (for public endpoints)
type IPRateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
}
3. HTTPS Enforcement
// Redirect HTTP to HTTPS
func httpsRedirectMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Header.Get("X-Forwarded-Proto") == "http" {
httpsURL := "https://" + c.Request.Host + c.Request.RequestURI
c.Redirect(http.StatusMovedPermanently, httpsURL)
c.Abort()
return
}
c.Next()
}
}
4. CORS Configuration
// Strict CORS configuration
corsConfig := cors.Config{
AllowOrigins: []string{
"https://yourdomain.com",
"https://app.yourdomain.com",
},
AllowMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowHeaders: []string{
"Origin", "Content-Type", "Authorization",
},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
router.Use(cors.New(corsConfig))
5. Content Security Policy
// Add CSP headers
func securityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:;")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Next()
}
}
VAPID Key Management
Key Rotation Strategy
When to Rotate:
- Every 6-12 months (scheduled)
- Suspected key compromise
- Team member departure
- Security audit requirement
Rotation Process:
// 1. Generate new keys
newPrivateKey, newPublicKey, _ := webpush.GenerateVAPIDKeys()
// 2. Store new keys (keep old keys for transition period)
os.Setenv("VAPID_PUBLIC_KEY_NEW", newPublicKey)
os.Setenv("VAPID_PRIVATE_KEY_NEW", newPrivateKey)
// 3. Support both keys temporarily
func (ps *PushService) sendToSubscription(subscription models.PushSubscription, payload types.PushPayload) error {
// Try new key first
err := ps.sendWithKey(subscription, payload, ps.newVapidPrivateKey, ps.newVapidPublicKey)
// Fallback to old key
if err != nil {
err = ps.sendWithKey(subscription, payload, ps.oldVapidPrivateKey, ps.oldVapidPublicKey)
}
return err
}
// 4. After transition period (e.g., 30 days), remove old keys
Key Storage Best Practices
❌ Never do this:
# Committed to git
git add .env
git commit -m "Add VAPID keys"
✅ Always do this:
# .gitignore
.env
.env.local
.env.production
# Kubernetes secrets
kubectl create secret generic vapid-keys \
--from-literal=public-key='BOzzMgOMwVyuziwHiI8...' \
--from-literal=private-key='BNw7fc1ayj3-Az-OJ8...'
# AWS Secrets Manager
aws secretsmanager create-secret \
--name vapid-keys \
--secret-string '{"public":"BOzz...","private":"BNw7..."}'
Input Validation
1. Subscription Endpoint Validation
func validateSubscriptionEndpoint(endpoint string) error {
// Must be HTTPS
if !strings.HasPrefix(endpoint, "https://") {
return errors.New("endpoint must use HTTPS")
}
// Valid URL format
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint URL: %w", err)
}
// Must be from trusted push services
allowedHosts := []string{
"fcm.googleapis.com",
"updates.push.services.mozilla.com",
"push.apple.com",
"wns2-*.notify.windows.com",
}
hostAllowed := false
for _, allowed := range allowedHosts {
if strings.Contains(u.Host, allowed) || u.Host == allowed {
hostAllowed = true
break
}
}
if !hostAllowed {
return fmt.Errorf("endpoint host not allowed: %s", u.Host)
}
return nil
}
2. Payload Validation
func validatePushPayload(payload types.PushPayload) error {
// Title required, max 65 characters
if payload.Title == "" {
return errors.New("title is required")
}
if len(payload.Title) > 65 {
return errors.New("title must be 65 characters or less")
}
// Body required, max 240 characters
if payload.Body == "" {
return errors.New("body is required")
}
if len(payload.Body) > 240 {
return errors.New("body must be 240 characters or less")
}
// URL validation (if provided)
if payload.URL != "" {
if !strings.HasPrefix(payload.URL, "/") && !strings.HasPrefix(payload.URL, "http") {
return errors.New("URL must be relative or absolute")
}
}
// Validate actions (max 2 on mobile, 4 on desktop)
if len(payload.Actions) > 4 {
return errors.New("maximum 4 actions allowed")
}
return nil
}
3. User Input Sanitization
import "html"
func sanitizePushPayload(payload *types.PushPayload) {
// Escape HTML in title and body
payload.Title = html.EscapeString(payload.Title)
payload.Body = html.EscapeString(payload.Body)
// Trim whitespace
payload.Title = strings.TrimSpace(payload.Title)
payload.Body = strings.TrimSpace(payload.Body)
}
SQL Injection Prevention
Use Parameterized Queries
❌ Vulnerable to SQL Injection:
// NEVER DO THIS
query := fmt.Sprintf("SELECT * FROM push_subscriptions WHERE user_id = '%s'", userID)
db.Raw(query).Scan(&subscriptions)
✅ Safe with Parameterized Queries:
// GORM automatically uses parameterized queries
db.Where("user_id = ?", userID).Find(&subscriptions)
// Raw query with parameters
db.Raw("SELECT * FROM push_subscriptions WHERE user_id = ?", userID).Scan(&subscriptions)
Input Validation for UUIDs
func validateUUID(id string) (uuid.UUID, error) {
parsedUUID, err := uuid.Parse(id)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid UUID format: %w", err)
}
return parsedUUID, nil
}
// Usage
func (ps *PushService) SendToUser(userIDStr string, payload types.PushPayload) error {
userID, err := validateUUID(userIDStr)
if err != nil {
return err
}
// Safe to use
var subscriptions []models.PushSubscription
ps.db.Where("user_id = ?", userID).Find(&subscriptions)
// ...
}
Authentication & Authorization
JWT Best Practices
// 1. Use strong secret keys (256+ bits)
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
if len(jwtSecret) < 32 {
log.Fatal("JWT_SECRET must be at least 32 characters")
}
// 2. Set appropriate expiration
claims := jwt.MapClaims{
"sub": userID,
"iat": time.Now().Unix(),
"exp": time.Now().Add(24 * time.Hour).Unix(), // 24 hours
"isAdmin": false,
}
// 3. Validate all claims
func validateJWT(tokenString string) (*jwt.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
// Validate expiration
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
if exp, ok := claims["exp"].(float64); ok {
if time.Unix(int64(exp), 0).Before(time.Now()) {
return nil, errors.New("token expired")
}
}
}
return token, nil
}
Role-Based Access Control
// Define roles
const (
RoleUser = "user"
RoleCaretaker = "caretaker"
RoleSpaceAdmin = "space_admin"
RoleSuperAdmin = "super_admin"
)
// Check permissions
func (pc *PushController) SendTestNotification(c *gin.Context) {
userRole, exists := c.Get("userRole")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Only super admins can send test notifications
if userRole != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
// Proceed with test notification...
}
Performance Optimization
1. Database Indexing
-- Essential indexes
CREATE INDEX CONCURRENTLY idx_push_subscriptions_user_active
ON push_subscriptions(user_id, is_active)
WHERE is_active = TRUE;
CREATE INDEX CONCURRENTLY idx_push_subscriptions_admin_active
ON push_subscriptions(admin_id, is_active)
WHERE is_active = TRUE;
CREATE INDEX CONCURRENTLY idx_push_notification_logs_sent_status
ON push_notification_logs(sent_at DESC, status);
-- Composite indexes for complex queries
CREATE INDEX CONCURRENTLY idx_push_subscriptions_composite
ON push_subscriptions(user_id, is_active, last_used_at DESC);
2. Connection Pooling
func setupDatabasePool(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
PrepareStmt: true, // Prepare statements
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
// Optimize connection pool
sqlDB.SetMaxOpenConns(100) // Max open connections
sqlDB.SetMaxIdleConns(10) // Max idle connections
sqlDB.SetConnMaxLifetime(time.Hour) // Max connection lifetime
sqlDB.SetConnMaxIdleTime(10 * time.Minute) // Max idle time
return db, nil
}
3. Caching Strategies
import "github.com/patrickmn/go-cache"
type CachedPushService struct {
*PushService
cache *cache.Cache
}
func NewCachedPushService(db *gorm.DB) *CachedPushService {
return &CachedPushService{
PushService: NewPushService(db),
cache: cache.New(5*time.Minute, 10*time.Minute),
}
}
// Cache subscription lookups
func (cps *CachedPushService) GetUserSubscriptions(userID uuid.UUID) ([]models.PushSubscription, error) {
cacheKey := fmt.Sprintf("user_subscriptions:%s", userID)
// Try cache first
if cached, found := cps.cache.Get(cacheKey); found {
return cached.([]models.PushSubscription), nil
}
// Query database
var subscriptions []models.PushSubscription
err := cps.db.Where("user_id = ? AND is_active = ?", userID, true).
Find(&subscriptions).Error
if err != nil {
return nil, err
}
// Cache for 5 minutes
cps.cache.Set(cacheKey, subscriptions, cache.DefaultExpiration)
return subscriptions, nil
}
// Invalidate cache on subscription changes
func (cps *CachedPushService) Subscribe(userID *uuid.UUID, adminID *uuid.UUID, req types.SubscribePushRequest) (*types.SubscribePushResponse, error) {
resp, err := cps.PushService.Subscribe(userID, adminID, req)
if err != nil {
return nil, err
}
// Invalidate cache
if userID != nil {
cps.cache.Delete(fmt.Sprintf("user_subscriptions:%s", *userID))
}
if adminID != nil {
cps.cache.Delete(fmt.Sprintf("admin_subscriptions:%s", *adminID))
}
return resp, nil
}
4. Batch Operations
// Batch insert notification logs
func (ps *PushService) batchInsertLogs(logs []models.PushNotificationLog) error {
batchSize := 100
for i := 0; i < len(logs); i += batchSize {
end := i + batchSize
if end > len(logs) {
end = len(logs)
}
batch := logs[i:end]
if err := ps.db.CreateInBatches(batch, batchSize).Error; err != nil {
return err
}
}
return nil
}
5. Goroutine Pooling
type WorkerPool struct {
tasks chan func()
workers int
}
func NewWorkerPool(workers int) *WorkerPool {
wp := &WorkerPool{
tasks: make(chan func(), 1000),
workers: workers,
}
// Start workers
for i := 0; i < workers; i++ {
go wp.worker()
}
return wp
}
func (wp *WorkerPool) worker() {
for task := range wp.tasks {
task()
}
}
func (wp *WorkerPool) Submit(task func()) {
wp.tasks <- task
}
// Usage in PushService
func (ps *PushService) SendToMultipleUsers(userIDs []uuid.UUID, payload types.PushPayload) error {
pool := NewWorkerPool(50) // 50 concurrent workers
for _, userID := range userIDs {
uid := userID // Capture for closure
pool.Submit(func() {
ps.SendToUser(uid, payload)
})
}
return nil
}
Code Organization
Project Structure
backend/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── config/
│ ├── database.go # DB setup
│ └── vapid.go # VAPID config
├── models/
│ ├── push_subscription.go # GORM models
│ └── push_notification_log.go
├── services/
│ ├── push_service.go # Core logic
│ └── push_service_test.go # Unit tests
├── controllers/
│ ├── push_controller.go # HTTP handlers
│ └── push_controller_test.go
├── routes/
│ └── push_routes.go # Route definitions
├── middleware/
│ ├── auth.go # Authentication
│ ├── rate_limiter.go # Rate limiting
│ └── cors.go # CORS
├── types/
│ └── push_types.go # Request/response types
├── utils/
│ ├── response.go # HTTP helpers
│ ├── device_detector.go # User agent parsing
│ └── validator.go # Input validation
├── workers/
│ ├── booking_reminder_worker.go # Background workers
│ └── notification_scheduler.go
├── metrics/
│ └── push_metrics.go # Prometheus metrics
├── migrations/
│ └── 001_create_push_tables.sql
├── .env.example
├── .gitignore
├── go.mod
└── README.md
Naming Conventions
// Use clear, descriptive names
✅ func (ps *PushService) SendToUser(userID uuid.UUID, payload types.PushPayload) error
❌ func (ps *PushService) Send(id string, p interface{}) error
// Constants in UPPER_SNAKE_CASE
const (
MAX_RETRY_ATTEMPTS = 3
DEFAULT_BATCH_SIZE = 100
WORKER_INTERVAL_MINUTES = 5
)
// Private methods start with lowercase
func (ps *PushService) sendToSubscription(...) error
func (ps *PushService) logSuccess(...) error
// Public methods start with uppercase
func (ps *PushService) SendToUser(...) error
func (ps *PushService) Subscribe(...) error
Testing Strategies
1. Unit Tests
// services/push_service_test.go
func TestPushService_Subscribe(t *testing.T) {
// Setup test database
db := setupTestDB(t)
defer teardownTestDB(db)
pushService := NewPushService(db)
userID := uuid.New()
req := types.SubscribePushRequest{
Endpoint: "https://fcm.googleapis.com/test-endpoint",
Keys: types.SubscriptionKeys{
P256dh: "test-p256dh-key",
Auth: "test-auth-key",
},
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0",
}
// Test subscription
response, err := pushService.Subscribe(&userID, nil, req)
assert.NoError(t, err)
assert.True(t, response.Success)
assert.NotEqual(t, uuid.Nil, response.SubscriptionID)
assert.Contains(t, response.DeviceName, "Chrome")
// Verify database
var subscription models.PushSubscription
err = db.Where("endpoint = ?", req.Endpoint).First(&subscription).Error
assert.NoError(t, err)
assert.Equal(t, userID, *subscription.UserID)
assert.True(t, subscription.IsActive)
}
2. Integration Tests
func TestPushNotificationFlow(t *testing.T) {
// Setup test environment
db := setupTestDB(t)
defer teardownTestDB(db)
pushService := NewPushService(db)
userID := uuid.New()
// 1. Subscribe
subResp, err := pushService.Subscribe(&userID, nil, testSubscriptionRequest())
assert.NoError(t, err)
// 2. Send notification
payload := types.PushPayload{
Title: "Test Notification",
Body: "This is a test",
}
err = pushService.SendToUser(userID, payload)
assert.NoError(t, err)
// 3. Verify log created
var log models.PushNotificationLog
err = db.Where("subscription_id = ?", subResp.SubscriptionID).
First(&log).Error
assert.NoError(t, err)
assert.Equal(t, "Test Notification", log.Title)
}
3. End-to-End Tests
// e2e/push-notifications.spec.ts
import { test, expect } from '@playwright/test';
test('push notification subscription flow', async ({ page, context }) => {
// Grant notification permission
await context.grantPermissions(['notifications']);
// Navigate to app
await page.goto('http://localhost:3000');
// Wait for service worker registration
await page.waitForFunction(() => {
return navigator.serviceWorker.controller !== null;
});
// Click subscribe button
await page.click('[data-testid="subscribe-notifications"]');
// Wait for subscription success message
await expect(page.locator('[data-testid="subscription-success"]')).toBeVisible();
// Verify subscription in backend
const devices = await page.evaluate(async () => {
const response = await fetch('/api/push/devices');
return response.json();
});
expect(devices.count).toBeGreaterThan(0);
});
4. Load Testing
// k6-load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '3m', target: 50 }, // Stay at 50 users
{ duration: '1m', target: 100 }, // Ramp up to 100 users
{ duration: '3m', target: 100 }, // Stay at 100 users
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Less than 1% failure rate
},
};
export default function () {
const payload = JSON.stringify({
endpoint: `https://fcm.googleapis.com/test-${__VU}-${__ITER}`,
keys: {
p256dh: 'test-p256dh-key',
auth: 'test-auth-key',
},
userAgent: 'k6-load-test',
});
const params = {
headers: {
'Content-Type': 'application/json',
'Cookie': 'auth-token=test-token',
},
};
const res = http.post('http://localhost:8081/api/push/subscribe', payload, params);
check(res, {
'status is 200': (r) => r.status === 200,
'response has subscriptionId': (r) => JSON.parse(r.body).subscriptionId !== undefined,
});
sleep(1);
}
Documentation Standards
Code Comments
// PushService handles all push notification operations.
//
// It manages subscriptions, sends notifications via the Web Push Protocol,
// tracks delivery status, and handles automatic retry logic.
//
// Example usage:
// pushService := NewPushService(db)
// payload := types.PushPayload{
// Title: "Booking Confirmed",
// Body: "Your booking is confirmed",
// }
// err := pushService.SendToUser(userID, payload)
type PushService struct {
db *gorm.DB
vapidPublicKey string
vapidPrivateKey string
vapidSubject string
}
// SendToUser sends a notification to all active devices of a user.
//
// Parameters:
// userID - UUID of the user to notify
// payload - Notification content (title, body, etc.)
//
// Returns:
// error - nil on success, error if no active subscriptions or send fails
//
// The notification is sent to all active devices in parallel using goroutines.
// Individual device failures do not cause the entire operation to fail.
func (ps *PushService) SendToUser(userID uuid.UUID, payload types.PushPayload) error {
// Implementation...
}
API Documentation
# openapi.yml
openapi: 3.0.0
info:
title: Push Notifications API
version: 1.0.0
paths:
/api/push/subscribe:
post:
summary: Subscribe to push notifications
description: Creates or updates a push subscription for the authenticated user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- endpoint
- keys
properties:
endpoint:
type: string
description: Browser push service URL
example: "https://fcm.googleapis.com/..."
keys:
type: object
required:
- p256dh
- auth
properties:
p256dh:
type: string
description: Public key for encryption
auth:
type: string
description: Authentication secret
userAgent:
type: string
description: Browser user agent
responses:
'200':
description: Subscription successful
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
message:
type: string
subscriptionId:
type: string
format: uuid
deviceName:
type: string
example: "Chrome on macOS"
'400':
description: Invalid request
'401':
description: Unauthorized
Deployment Checklist
Pre-Deployment
- [ ] VAPID keys generated and configured
- [ ] Environment variables set for all environments
- [ ] Database migrations applied
- [ ] All tests passing (unit, integration, E2E)
- [ ] Code reviewed and approved
- [ ] Security audit completed
- [ ] Performance benchmarks met
- [ ] Documentation updated
Deployment
- [ ] Deploy to staging environment
- [ ] Run smoke tests in staging
- [ ] Verify service health checks passing
- [ ] Check metrics in Grafana
- [ ] Deploy to production with rolling update
- [ ] Monitor error rates during deployment
- [ ] Verify zero downtime
Post-Deployment
- [ ] Monitor metrics for 1 hour
- [ ] Check error tracking (Sentry)
- [ ] Verify push notifications sending successfully
- [ ] Test subscription flow end-to-end
- [ ] Review logs for anomalies
- [ ] Update status page
- [ ] Notify stakeholders of deployment
Summary
You now have:
✅ Security best practices (secrets, HTTPS, CORS, CSP)
✅ VAPID key management and rotation
✅ Comprehensive input validation
✅ SQL injection prevention
✅ Authentication & authorization patterns
✅ Performance optimization techniques
✅ Code organization standards
✅ Testing strategies (unit, integration, E2E, load)
✅ Documentation standards
✅ Complete deployment checklist
Next Steps
➡️ Part 8: Complete Reference & FAQ
Part 8 (final) will cover:
- Complete API reference
- Frequently Asked Questions (FAQ)
- Troubleshooting guide
- Browser compatibility matrix
- Migration guides
- Glossary of terms
- Additional resources
- Community support
Top comments (0)