Skip to content

Security

OpenCheckout is designed to handle financial payment instructions. Security is not optional. This page documents every security mechanism in the system.

API Key Authentication

All checkout API endpoints (/api/checkout/*) require a valid API key passed as a Bearer token in the Authorization header:

Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Key Storage

API keys are never stored in plaintext. When a key is created, it is immediately hashed with SHA-256:

stored_hash = SHA-256(plaintext_key)

When a request arrives, the provided key is hashed and compared against the stored hash. The plaintext key exists only in memory during the comparison and is never logged or persisted.

Key Lifecycle

  • Keys can be created from the dashboard at /dashboard/keys
  • Active keys authenticate API requests
  • Revoked keys are permanently invalidated
  • Revocation is immediate and irreversible
  • Dashboard sign-in exchanges the API key for an eight-hour, encrypted HttpOnly, Secure, SameSite=Strict cookie; client-side JavaScript cannot read it

Private Key Encryption

Each merchant has an Ed25519 private key used to sign Open Payments API requests. Private keys, webhook secrets, and pending grant continuation credentials are encrypted at rest using AES-256-GCM. Grant credentials are purged after completion.

Encryption Scheme

1. Master key = 32 bytes from ENCRYPTION_KEY environment variable
2. For each merchant private key:
- Generate random 32-byte salt
- Generate random 16-byte IV
- Derive encryption key = scrypt(master_key, salt, key_length=32)
- Encrypt private key with AES-256-GCM
- Store: base64(salt + iv + auth_tag + ciphertext)

Key Management

  • The ENCRYPTION_KEY environment variable must be a 64-character hex string (32 bytes)
  • Generate it with: openssl rand -hex 32
  • Store it securely. If lost, all merchant private keys become unrecoverable
  • Encryption-key rotation requires decrypting and re-encrypting stored secrets during a planned maintenance operation; re-running setup creates another merchant and is not a rotation procedure

HTTP Message Signatures

Every request to the Open Payments API is signed using HTTP Message Signatures (RFC 9421) with the Ed25519 variant of EdDSA.

What Gets Signed

The signature covers:

  • Request method (@method)
  • Request URL (@target-uri)
  • Content-Type header
  • Content-Digest header (SHA-512 of the request body)
  • Authorization header
  • Content-Length header

Key Binding

The merchant’s public key is registered at their wallet address’s JWKS endpoint ({wallet_address}/jwks.json). When an authorization server receives a signed request, it:

  1. Extracts the keyId from the Signature-Input header
  2. Fetches the public key from the client’s JWKS endpoint
  3. Verifies the signature against the covered components

This binds the client to the grant and prevents request tampering.

Webhook Signatures

Outgoing webhooks are signed with HMAC-SHA256. Each webhook request includes an OpenCheckout-Signature header:

OpenCheckout-Signature: t=1718400000,v1=abc123def456...

Verification

To verify a webhook on your backend:

1. Extract the timestamp (t=) and signature (v1=) from the header
2. Compute: expected = HMAC-SHA256(webhook_secret, timestamp + "." + payload)
3. Compare expected against the v1 signature using constant-time comparison
4. Optionally reject timestamps older than 5 minutes to prevent replay attacks

Secret Management

  • The webhook secret is a 32-byte random value, generated with crypto.randomBytes()
  • It is encrypted in the database. A masked prefix is shown during normal reads
  • Regeneration returns the new plaintext once so it can be copied, then subsequent reads are masked

Redirect Security

Open Redirect Prevention

Every redirect URL is validated before use:

  • Must use https: (blocks http:, javascript:, data:, and file:)
  • Must parse as a valid URL
  • success_url and cancel_url are validated at session creation via Zod’s .url() check
  • The success page performs an additional protocol check before executing window.location.href

Interaction Hash Verification

When the customer approves a payment at their authorization server, the server redirects back with a hash parameter. OpenCheckout verifies this hash:

expected = SHA-256(client_nonce + "\n" + server_nonce + "\n" + interact_ref + "\n" + auth_server_url)

This confirms that the redirect genuinely originated from the authorization server and was not forged.

If hash verification fails, OpenCheckout fails closed and does not continue the grant or create an outgoing payment.

Server-Side Request Forgery (SSRF) Prevention

Wallet, webhook, incoming-payment, and merchant redirect URLs are validated before server-side use:

  • Must use https:
  • Blocked: localhost, 127.0.0.1, ::1
  • Blocked: private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Blocked: link-local addresses (169.254.0.0/16)

HTTP Security Headers

Every API response includes:

HeaderValuePurpose
X-Content-Type-OptionsnosniffPrevent MIME type sniffing
X-Frame-OptionsDENYPrevent clickjacking
Referrer-Policystrict-origin-when-cross-originControl referrer information
Content-Security-PolicyRestrictive, with development-only unsafe-evalLimit script, frame, object, and connection sources
Permissions-PolicyCamera, microphone, location, and payment disabledDisable unused browser capabilities
Strict-Transport-SecurityOne year in productionEnforce HTTPS

Server Hardening

  • The Docker container runs as a non-root user (nextjs)
  • The SQLite database uses WAL mode with foreign keys enforced
  • Console error logging captures failures without exposing secrets
  • No development dependencies are included in the production Docker image
  • Environment files, databases, PEM files, and raw keys are excluded from the Docker build context
  • The @interledger/open-payments and better-sqlite3 packages are excluded from Turbopack bundling to preserve native module compatibility

Reporting Security Issues

If you discover a security vulnerability, please report it privately to the maintainers. Do not open a public issue.