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
- Security Guide
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_secretandprivate_keyininspectoutput and error messages, so they will not appear in Rails logs even if aClientConfigobject 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_keyandcertificate_chainidentify the client and sign registration software statements.trusted_anchors,crls, andrevocation_checkervalidate the server’s discoverysigned_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:
- Generate a new key pair with a new
kid - Add the new public key to your JWKS endpoint
- Update your application to use the new private key
- 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.