Class: Sendgrid::SignatureVerifier

Inherits:
Object
  • Object
show all
Defined in:
app/services/sendgrid/signature_verifier.rb

Overview

Verifies SendGrid Event Webhook signatures using ECDSA
Reference: https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features

When signature verification is enabled in SendGrid:

  • SendGrid generates a public/private key pair
  • Each webhook POST includes:
    • X-Twilio-Email-Event-Webhook-Signature: Base64-encoded ECDSA signature
    • X-Twilio-Email-Event-Webhook-Timestamp: Unix timestamp
  • The signature is computed over timestamp + raw payload

Multiple SendGrid Subusers:

  • Each subuser (warmlyyours, warmlyyours_transaction, warmlyyours_marketing) has its own key
  • Pass the subuser name to look up the correct key from credentials
  • Keys stored at: sendgrid_api..webhook_verification_key

Examples:

With subuser

verifier = Sendgrid::SignatureVerifier.new(subuser: 'warmlyyours_transaction')
if verifier.verify(request)
  # Process the webhook
else
  # Reject the request
end

Legacy (no subuser)

verifier = Sendgrid::SignatureVerifier.new
# Will check ENV or fall back to default 'warmlyyours' subuser

Constant Summary collapse

SIGNATURE_HEADER =

Header names as they appear in Rack env

'HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE'
TIMESTAMP_HEADER =
'HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP'
DEFAULT_SUBUSER =

Default subuser if none specified

'warmlyyours'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(public_key: nil, subuser: nil) ⇒ SignatureVerifier

Returns a new instance of SignatureVerifier.



43
44
45
46
# File 'app/services/sendgrid/signature_verifier.rb', line 43

def initialize(public_key: nil, subuser: nil)
  @subuser = subuser.presence || DEFAULT_SUBUSER
  @public_key = public_key || fetch_public_key
end

Instance Attribute Details

#subuserObject (readonly)

Returns the value of attribute subuser.



41
42
43
# File 'app/services/sendgrid/signature_verifier.rb', line 41

def subuser
  @subuser
end

Instance Method Details

#enabled?Boolean

Check if signature verification is enabled

Returns:

  • (Boolean)

    true if a public key is configured



60
61
62
# File 'app/services/sendgrid/signature_verifier.rb', line 60

def enabled?
  @public_key.present?
end

#fetch_public_keyObject

Fetch the public key for the current subuser from credentials
Priority: ENV > credentials for specific subuser



50
51
52
53
54
55
56
# File 'app/services/sendgrid/signature_verifier.rb', line 50

def fetch_public_key
  # First check ENV (useful for testing or overriding)
  return ENV['SENDGRID_WEBHOOK_VERIFICATION_KEY'] if ENV['SENDGRID_WEBHOOK_VERIFICATION_KEY'].present?

  # Look up from credentials: sendgrid_api.<subuser>.webhook_verification_key
  Heatwave::Configuration.fetch(:sendgrid_api, @subuser.to_sym, :webhook_verification_key)
end

#verify(request) ⇒ Boolean

Verify a request's signature

Parameters:

  • request (ActionDispatch::Request)

    The incoming request

Returns:

  • (Boolean)

    true if signature is valid (or if verification is disabled)



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'app/services/sendgrid/signature_verifier.rb', line 67

def verify(request)
  return true unless enabled?

  signature = request.headers[SIGNATURE_HEADER] ||
              request.env[SIGNATURE_HEADER] ||
              request.headers['X-Twilio-Email-Event-Webhook-Signature']

  timestamp = request.headers[TIMESTAMP_HEADER] ||
              request.env[TIMESTAMP_HEADER] ||
              request.headers['X-Twilio-Email-Event-Webhook-Timestamp']

  payload = request.raw_post

  if signature.blank? || timestamp.blank?
    log_verification_failure('Missing signature or timestamp header', request)
    return false
  end

  result = verify_signature(payload, signature, timestamp)
  log_verification_failure('Invalid signature', request) unless result
  result
end

#verify_signature(payload, signature, timestamp) ⇒ Boolean

Verify a signature directly

Parameters:

  • payload (String)

    Raw request body

  • signature (String)

    Base64-encoded signature from header

  • timestamp (String)

    Timestamp from header

Returns:

  • (Boolean)

    true if signature is valid



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/services/sendgrid/signature_verifier.rb', line 95

def verify_signature(payload, signature, timestamp)
  return false if @public_key.blank?

  event_webhook = SendGrid::EventWebhook.new
  ec_public_key = event_webhook.convert_public_key_to_ecdsa(@public_key)
  event_webhook.verify_signature(ec_public_key, payload, signature, timestamp)
rescue SendGrid::EventWebhook::NotSupportedError => e
  Rails.logger.error "[SendGrid Signature] #{e.message}"
  # If verification isn't supported (JRuby), allow the request but log it
  true
rescue StandardError => e
  Rails.logger.error "[SendGrid Signature] Verification error: #{e.message}"
  false
end