Security Guide

This guide covers security requirements and best practices for every Safire integration, regardless of client type. Apply these rules in all production deployments.

Table of contents

  1. Security Guide
    1. HTTPS and Redirect URI Rules
    2. Credential Protection
      1. Client Secrets (Confidential Symmetric)
      2. Private Keys and Client Certificates
    3. Token and Session Security
      1. Token Storage
      2. CSRF State Parameter
      3. PKCE Code Verifier
    4. Key Rotation and Scope Minimization
      1. Symmetric Secret Rotation
      2. Asymmetric Key Rotation
      3. Scope Minimization

HTTPS and Redirect URI Rules

All production FHIR integrations must use HTTPS. Safire enforces this at configuration time — HTTP URIs are rejected unless the caller explicitly opts into the local-development loopback exception.

# config/environments/production.rb
config.force_ssl = true
# ✅ Always use HTTPS in production
config = Safire::ClientConfig.new(
  redirect_uri: 'https://myapp.example.com/auth/callback',
  # ...
)

# ❌ Raises Safire::Errors::ConfigurationError
config = Safire::ClientConfig.new(
  redirect_uri: 'http://myapp.example.com/auth/callback',
  # ...
)

Localhost HTTP is permitted only when the application enables it deliberately:

# ✅ Allowed for local development only
config = Safire::ClientConfig.new(
  base_url: 'http://localhost:3000/fhir',
  redirect_uri: 'http://localhost:3000/auth/callback',
  allow_insecure_localhost: ENV['APP_ENV'] == 'development'
)

Credential Protection

Never expose client secrets or private keys in logs, responses, or version control.

Client Secrets (Confidential Symmetric)

# ❌ NEVER: log the secret
Rails.logger.info("Using secret: #{client_secret}")

# ❌ NEVER: render in a response
render json: { client_secret: ENV['SMART_CLIENT_SECRET'] }

# ❌ NEVER: commit .env to version control
# Add to .gitignore: .env

Load secrets from a secure source:

# Environment variable
config = Safire::ClientConfig.new(
  client_secret: ENV.fetch('SMART_CLIENT_SECRET'),
  # ...
)

# Rails credentials
config = Safire::ClientConfig.new(
  client_secret: Rails.application.credentials.smart[:client_secret],
  # ...
)

# AWS Secrets Manager
require 'aws-sdk-secretsmanager'

def fetch_client_secret
  client = Aws::SecretsManager::Client.new
  secret = client.get_secret_value(secret_id: 'smart/credentials')
  JSON.parse(secret.secret_string)['client_secret']
end

Private Keys and Client Certificates

# ❌ NEVER: render or log the key
render json: { private_key: @private_key.to_pem }

# Add to .gitignore: *.pem, *.key

Load private keys securely:

# From a file path
private_key = OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH']))

# From a PEM string in an env var
private_key = OpenSSL::PKey::RSA.new(ENV['SMART_PRIVATE_KEY_PEM'])

# From Rails credentials
private_key = OpenSSL::PKey::RSA.new(
  Rails.application.credentials.smart[:private_key_pem]
)

# From AWS Secrets Manager
def fetch_private_key
  client = Aws::SecretsManager::Client.new
  secret = client.get_secret_value(secret_id: 'smart/private-key')
  OpenSSL::PKey::RSA.new(secret.secret_string)
end

For SMART confidential-asymmetric flows, use strong keys compatible with the SMART signing algorithms:

# RSA: minimum 2048-bit, 4096-bit recommended
key = OpenSSL::PKey::RSA.generate(4096)

# EC: must use P-384 curve (required by SMART spec for ES384)
key = OpenSSL::PKey::EC.generate('secp384r1')

Safire automatically masks client_secret and private_key in inspect output and error messages, so they will not appear in Rails logs even if a ClientConfig object is accidentally logged.

UDAP Dynamic Client Registration additionally requires a leaf-first client certificate chain. The certificates contain public material, but they identify the client’s operational signing identity and can be large, so Safire masks certificate_chain in ClientConfig#inspect and #to_hash as well.

udap_client = Safire::Client.new(
  {
    base_url: 'https://fhir.example.com',
    private_key: Rails.application.credentials.udap[:private_key_pem],
    certificate_chain: [
      File.read(ENV.fetch('UDAP_CLIENT_CERTIFICATE_PATH')),
      File.read(ENV.fetch('UDAP_CLIENT_ISSUING_CA_PATH'))
    ]
  },
  protocol: :udap
)

Keep the two UDAP trust directions separate:

  • private_key and certificate_chain identify the client and sign registration software statements.
  • trusted_anchors, crls, and revocation_checker validate the server’s discovery signed_metadata.

Never use verify_chain: false or allow_insecure_localhost: true in production. The generated credentials and self-declaration JWTs in the unbundled Sinatra demo are development fixtures, not production trust material. See UDAP Dynamic Client Registration for the complete trust model.


Token and Session Security

Token Storage

Always store tokens server-side. Never expose them to client-side code.

# ✅ DO: Server-side session
session[:access_token] = tokens['access_token']

# ✅ DO: Encrypted database column
user.update(encrypted_access_token: cipher.encrypt(tokens['access_token']))

# ❌ DON'T: Plain cookie
cookies[:access_token] = tokens['access_token']

# ❌ DON'T: JSON response to the browser
render json: { access_token: tokens['access_token'] }

CSRF State Parameter

Safire generates a 32-character hex state value (128 bits of entropy) automatically. Always verify it on callback and delete it immediately after:

def callback
  unless params[:state] == session[:oauth_state]
    render plain: 'Invalid state', status: :unauthorized
    return
  end

  # ... exchange code for tokens ...

  session.delete(:oauth_state)   # ✅ Delete after validation
  session.delete(:code_verifier) # ✅ Delete after token exchange
end

PKCE Code Verifier

Safire generates the code verifier automatically. Store it server-side only and discard it immediately after the token exchange — never send it to the client or include it in a URL.

# Store on launch
session[:code_verifier] = auth_data[:code_verifier]

# Delete immediately after exchange
session.delete(:code_verifier)

Key Rotation and Scope Minimization

Symmetric Secret Rotation

Support two secrets during rotation to allow a zero-downtime rollover:

module SmartSecretRotation
  def build_smart_client
    create_client(primary_secret)
  rescue Safire::Errors::TokenError => e
    raise unless e.error_code == 'invalid_client'
    create_client(secondary_secret) # Fall back during rotation
  end

  def primary_secret   = ENV['SMART_CLIENT_SECRET']
  def secondary_secret = ENV['SMART_CLIENT_SECRET_PREVIOUS']
end

Asymmetric Key Rotation

Publish both old and new public keys simultaneously in your JWKS endpoint during rotation:

{
  "keys": [
    { "kid": "key-v1", "kty": "RSA", "use": "sig", ... },
    { "kid": "key-v2", "kty": "RSA", "use": "sig", ... }
  ]
}

Rotation steps:

  1. Generate a new key pair with a new kid
  2. Add the new public key to your JWKS endpoint
  3. Update your application to use the new private key
  4. Remove the old public key from JWKS after a grace period (allow in-flight tokens to expire)

Scope Minimization

Request only the scopes your application needs. Broad wildcard scopes increase the impact of a compromised token.

# ✅ Request specific resource types
scopes: ['patient/Patient.read', 'patient/Observation.read']

# ❌ Avoid unless truly necessary
scopes: ['patient/*.*']

You can also reduce scopes at refresh time:

client.refresh_token(
  refresh_token: session[:refresh_token],
  scopes: ['patient/Patient.read'] # Must be a subset of the original grant
)

See also: Configuration Guide for ssl_options and log_http settings that affect security behaviour.


Back to Top ↑

This site uses Just the Docs, a documentation theme for Jekyll.