This document is the authoritative security reference for SorobanPay. It covers the on-chain security model, authorization audit results, backend secrets management, circuit breaker runbook, frontend security, known limitations, and dependency auditing.
- Contract Security Model
- Authorization Audit (SC-20)
- Backend Secrets Management
- Circuit Breaker Runbook (SC-25)
- Frontend Security
- Known Limitations and Mitigations
- Dependency Security
- Security Disclosure Policy
SorobanPay never holds token balances. All payment transfers go directly from subscriber → merchant via SEP-41 transfer(). The contract has no treasury address, no escrow wallet, and no upgrade authority over token contracts.
This means:
- A compromised contract cannot drain funds it does not hold.
- A compromised backend cannot move funds (it never submits token transfers).
- Subscribers retain full custody of their tokens between payment windows.
Every entry point calls require_auth() before any state mutation:
| Entry point | Who must authorize | What they authorize |
|---|---|---|
subscribe |
subscriber |
Create or overwrite the subscription record |
execute_payment |
merchant |
Collect the next due payment |
execute_payment_batch |
merchant |
Collect payments for a batch of subscribers |
cancel |
subscriber |
Remove the subscription record |
get_subscription |
(no auth) | Read-only view — no state change |
version |
(no auth) | Read-only — returns version symbol |
contract_name |
(no auth) | Read-only — returns name symbol |
require_auth() is enforced by the Soroban host, not by application logic. A missing or invalid signature causes the entire transaction to fail before any code in the entry point runs.
Subscribers grant a SEP-41 token allowance to the contract address:
subscriber.approve(contract_address, amount, expiry_ledger)
The contract's execute_payment then calls token.transfer(subscriber, merchant, amount). This means:
- Revoking the allowance (
approve(contract_address, 0)) prevents all future payments regardless of on-chain subscription state — subscribers have a unilateral emergency stop. - Reducing the allowance below the subscription amount causes
execute_paymentto fail withTransferFailed. - The contract does not require an allowance equal to all future payments — only enough for the next interval.
execute_payment checks now >= next_payment before attempting any transfer. This check uses the Soroban ledger timestamp, which is set by the network validators and cannot be manipulated by the transaction submitter.
if now < data.next_payment {
return Err(ContractError::PaymentNotDue);
}
Merchants cannot front-run or double-collect payments within the same billing window.
subscribe validates all inputs before touching storage:
| Check | Error | Condition |
|---|---|---|
| Self-subscription | SelfSubscription |
subscriber == merchant |
| Positive amount | AmountMustBePositive |
amount <= 0 |
| Amount ceiling | AmountTooLarge |
amount > 10^18 |
| Interval floor | IntervalTooShort |
interval < 86400 (1 day) |
| Interval ceiling | IntervalTooLong |
interval > 31536000 (365 days) |
| Timestamp validity | InvalidTimestamp |
ledger timestamp is 0 or would overflow |
Persistent entries have a TTL. Expired entries return None on read — identical to a cancelled subscription from the contract's perspective. execute_payment on an expired entry returns NoActiveSubscription, not a token transfer error.
See docs/architecture.md for the full TTL lifecycle.
This section documents which address is expected to authenticate at each contract entry point and what an attacker could do if they impersonated a different party.
| Entry point | Authenticating address | Auth position in code | Attack if wrong party |
|---|---|---|---|
subscribe |
subscriber |
Line 1, before any state read | Merchant cannot create subscriptions on behalf of users without their key |
execute_payment |
merchant |
Line 1, before storage load | Subscriber cannot block collection by impersonating merchant; random address cannot trigger transfers |
execute_payment_batch |
merchant |
Line 1, before loop | Only the declared merchant can batch-collect; one merchant cannot collect on behalf of another |
cancel |
subscriber |
Line 1, before storage check | Merchant cannot cancel a subscriber's subscription unilaterally |
This is a deliberate design choice. The merchant bears responsibility for collecting payments on schedule — authorizing as merchant means:
- The subscriber does not need to be online to approve each collection (recurrence model).
- The subscriber's auth (token allowance) is a separate, out-of-band authorization that limits transfer scope.
- Merchants sign with their own key, creating an auditable on-chain record of who collected what and when.
The subscriber's signature on subscribe is the primary consent signal. By signing this transaction, the subscriber:
- Agrees to the specific amount, interval, token, and merchant.
- Authorizes the contract to record this agreement.
- Does not pre-authorize any token transfers — the token allowance is separate.
This two-step consent model (subscribe + approve) means revoking either the subscription (cancel) or the token allowance immediately halts future payments.
As a contributor, always call require_auth() as the first statement in any entry point, before any storage reads, logging, or external calls. This prevents auth bypass via state-dependent short-circuits.
// CORRECT
pub fn subscribe(env: Env, subscriber: Address, ...) -> Result<(), ContractError> {
subscriber.require_auth(); // ← FIRST LINE
// ... validation and storage writes
}
// WRONG — auth after a state read could be bypassed
pub fn subscribe(env: Env, subscriber: Address, ...) -> Result<(), ContractError> {
let existing = env.storage().persistent().get(&key); // ← state read BEFORE auth
subscriber.require_auth();
// ...
}Add these patterns to .gitignore (already present in the repo):
.env
.env.local
.env.production
*.pem
*.key
Use a pre-commit hook to catch accidental staging of secret files:
# .git/hooks/pre-commit (chmod +x)
#!/bin/sh
STAGED=$(git diff --cached --name-only)
if echo "$STAGED" | grep -qE '^(backend/\.env|\.env\.local|\.env\.production)$'; then
echo "ERROR: Secret file staged for commit. Unstage it: git reset HEAD <file>"
exit 1
fi
# Optional: use gitleaks for deeper scanning
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --no-banner
fi| Variable | Sensitivity | Notes |
|---|---|---|
DATABASE_URL |
🔴 High | Contains credentials; never log the full URL |
RPC_URL |
🟡 Medium | High if it embeds a paid-provider API key (e.g., ValidationCloud) |
NETWORK_PASSPHRASE |
🟢 Low | Public Stellar constant; still env-configurable for flexibility |
CONTRACT_ID |
🟢 Low | Public on-chain address |
WEBHOOK_SECRET |
🔴 High | Used to compute HMAC-SHA256 signatures; exposure allows forged webhooks |
OPERATOR_SECRET |
⛔ Privileged | Stellar keypair for optional PaymentScheduler; see note below |
PORT |
🟢 Low | Server port; no security impact |
OPERATOR_SECRET is a Stellar secret key (S…) used by the optional PaymentScheduler to submit execute_payment transactions. If compromised:
- The attacker can collect payments for any subscription on the contract where the operator is the merchant.
- They cannot steal funds held by the contract (there are none) or modify subscriptions.
Never set OPERATOR_SECRET unless the PaymentScheduler is actively needed. If not set, the scheduler is disabled and the backend is fully read-only.
cd backend
cp .env.example .env
# Edit .env — fill in real DATABASE_URL, RPC_URL, CONTRACT_ID
# NEVER commit .envLoad secrets at runtime via dotenv:
import 'dotenv/config'; // top of src/index.tsSet variables in the platform's environment dashboard. No secret-store SDK needed. The app reads from process.env at runtime.
Fly.io example:
flyctl secrets set DATABASE_URL="postgresql://user:pass@host:5432/db"
flyctl secrets set WEBHOOK_SECRET="$(openssl rand -hex 32)"Render example: Settings → Environment → Add Environment Variable (mark as secret).
For AWS-hosted deployments needing audit trails and automatic rotation:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
async function loadSecrets() {
const client = new SecretsManagerClient({ region: 'us-east-1' });
const { SecretString } = await client.send(
new GetSecretValueCommand({ SecretId: 'sorobanpay/backend/prod' })
);
if (!SecretString) throw new Error('Secret not found');
const secrets = JSON.parse(SecretString);
process.env.DATABASE_URL = secrets.DATABASE_URL;
process.env.WEBHOOK_SECRET = secrets.WEBHOOK_SECRET;
// Assign other secrets before starting the Express server
}
await loadSecrets();
// then import and start the appStore secrets as a JSON object in a single AWS Secrets Manager entry. Enable automatic rotation for DATABASE_URL using the built-in RDS Lambda rotator.
# Store
vault kv put secret/sorobanpay/backend \
DATABASE_URL="postgresql://..." \
WEBHOOK_SECRET="..."
# Retrieve in app startup (using node-vault or vault agent sidecar)# docker-compose.yml
secrets:
webhook_secret:
external: true
db_url:
external: true
services:
backend:
secrets:
- webhook_secret
- db_url
environment:
WEBHOOK_SECRET_FILE: /run/secrets/webhook_secret
DATABASE_URL_FILE: /run/secrets/db_urlRead in app:
import { readFileSync } from 'fs';
function readSecret(envKey: string, fileEnvKey: string): string {
const filePath = process.env[fileEnvKey];
if (filePath) return readFileSync(filePath, 'utf-8').trim();
return process.env[envKey] ?? '';
}
const webhookSecret = readSecret('WEBHOOK_SECRET', 'WEBHOOK_SECRET_FILE');DATABASE_URL (password rotation):
- Create a new database user with the same privileges as the old one.
- Update the secret in your store (AWS Secrets Manager, Vault, or platform dashboard).
- Trigger a rolling restart of the backend service (
flyctl restart/render manual deploy/kubectl rollout restart). - Verify the backend connects successfully (check
/healthendpoint). - Drop the old database user.
- Estimated downtime: 0 seconds (rolling restart).
WEBHOOK_SECRET rotation:
- Generate a new secret:
openssl rand -hex 32. - Update both the backend environment and the webhook sender's configuration at the same time (coordinate with merchants using webhooks).
- During the brief transition window, verify both old and new signatures to avoid dropped events:
function verifySignature(payload: string, signature: string, secrets: string[]): boolean { return secrets.some(secret => { const expected = createHmac('sha256', secret).update(payload).digest('hex'); return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); }); }
- Remove the old secret after all senders have switched.
OPERATOR_SECRET rotation:
- Generate a new Stellar keypair:
stellar keys generate new-operator --network mainnet. - Fund the new account with enough XLM for fees.
- Update
OPERATOR_SECRETin the secret store and redeploy. - Revoke the old keypair by setting it to zero balance or removing from any multisig setups.
A "circuit breaker" for SorobanPay means stopping all payment collection and communicating clearly to users while a fix is prepared. Because the contract is non-upgradeable, the primary levers are:
- Subscriber action: Revoke token allowance.
- Backend action: Stop the
PaymentScheduler. - Merchant action: Stop calling
execute_payment. - Protocol action: Deploy a new contract version and migrate.
- Identify the affected entry point(s) and the nature of the issue:
- Logic bug (wrong amount collected, wrong party authorized)?
- Reentrancy or state corruption?
- Dependency vulnerability (SEP-41 token bug)?
- Determine scope: single subscription, all subscriptions for a token, or protocol-wide.
- Open a private incident channel (Slack / Discord / email) with core contributors.
- Do not disclose publicly until mitigations are in place unless data of subscribers is at immediate risk.
Backend — disable PaymentScheduler:
# Fly.io
flyctl secrets unset OPERATOR_SECRET
flyctl restart
# Render
# Remove OPERATOR_SECRET in dashboard → manual deploy
# Docker Compose
docker compose stop backend
# Edit docker-compose.yml: remove OPERATOR_SECRET
docker compose up -d backend
# Kubernetes
kubectl set env deployment/backend OPERATOR_SECRET="" -n sorobanpay
kubectl rollout restart deployment/backend -n sorobanpayMerchant — stop calling execute_payment manually:
Contact all merchants integrating the contract directly (via webhook, email, or Discord) and instruct them to pause automated collection scripts.
Subscriber emergency stop (self-service):
Instruct subscribers to revoke the token allowance via Freighter or the Stellar Laboratory:
stellar contract invoke \
--id <TOKEN_CONTRACT_ID> --source <subscriber-key> --network mainnet \
-- approve \
--from <subscriber-address> \
--spender <contract-address> \
--amount 0 \
--expiration_ledger 0Publish this command in your status page and social channels immediately.
- Reproduce the issue in a local test environment (
make test). - Write a failing test that captures the bug.
- Implement the fix.
- Review: get sign-off from at least two contributors who are not the patch author.
- Deploy to testnet and verify the fix with the failing test now passing.
Because Soroban contracts are immutable once deployed, fixing a contract bug requires deploying a new contract instance:
# Deploy new version to mainnet
STELLAR_NETWORK=mainnet STELLAR_IDENTITY=my-mainnet-id bash deploy/deploy.sh
NEW_CONTRACT_ID=$(...)
echo "New contract: $NEW_CONTRACT_ID"Migration steps:
- Announce the new contract address via all channels.
- Update
NEXT_PUBLIC_CONTRACT_IDin frontend.env.localand redeploy the frontend. - Update
CONTRACT_IDin the backend environment and restart. - Communicate to merchants that they must update their integrations to the new contract address.
- Instruct subscribers to re-subscribe on the new contract (subscriptions are not portable between contract instances).
- Write a post-mortem: timeline, root cause, impact assessment, remediation.
- Publish a public summary (may omit sensitive technical details until patched).
- Update this runbook with any new lessons learned.
- File a CVE if the vulnerability is in a shared dependency.
| Level | Criteria | Response time | Public disclosure |
|---|---|---|---|
| Critical | Funds at risk, active exploitation | Immediate (< 1 hour) | After mitigation |
| High | Auth bypass, data integrity | < 4 hours | After fix deployed |
| Medium | DoS, information leak | < 24 hours | With fix |
| Low | Edge case, no immediate risk | Next release | With fix |
Configure the following CSP in next.config.mjs to restrict resource loading:
// next.config.mjs
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
connect-src 'self'
https://soroban-testnet.stellar.org
https://mainnet.stellar.validationcloud.io
https://horizon-testnet.stellar.org
https://horizon.stellar.org;
frame-ancestors 'none';
`.replace(/\n/g, ' ');
export default {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: cspHeader },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};Note:
unsafe-evalis currently required for certain Stellar SDK WASM operations. Track stellar-sdk#1234 for resolution; remove once the SDK no longer requires it.
Freighter's security model:
- The user's secret key never leaves the Freighter extension. The frontend receives a signed XDR envelope, not the private key.
- The frontend cannot construct a transaction that transfers more than the user approved — the Freighter UI shows the exact transaction to be signed.
- A compromised frontend can show a misleading UI, but cannot forge signatures.
Contributor guidelines for transaction construction:
- Always use
simulateTransactionbefore prompting for a signature — this catches errors before the user sees a signing prompt. - Never modify a transaction after simulation and before signing.
- Display the decoded transaction details in the UI (
merchant,amount,token,interval) so users can verify before signing. - Set transaction timeout (
timebounds) to a reasonable value (e.g., 5 minutes) to prevent replay of stale signed transactions.
The frontend performs client-side validation (frontend/lib/validation.ts) before constructing any transaction:
- Stellar address format (
StrKey.isValidEd25519PublicKey) - Amount bounds (positive, not exceeding
MAX_AMOUNT) - Interval bounds (86400 – 31536000 seconds)
- Token address format
Client-side validation is UX — it does not replace on-chain validation. The contract enforces all constraints independently.
Warn users:
- Only install Freighter from the official Chrome Web Store or Firefox Add-ons.
- Verify the URL is
freighter.appbefore connecting. - SorobanPay will never ask for your seed phrase.
Limitation: Payment collection transactions are visible in the mempool before inclusion. A miner or validator could theoretically front-run execute_payment to collect a payment in a different transaction order, though there is no direct financial incentive to do so since the funds go to the declared merchant.
Mitigation: Soroban's fee market does not enable ordering by fee tip at the contract-call level. The time-lock (now >= next_payment) bounds the collection window but does not prevent ordering attacks within that window.
Risk level: Low. Front-running execute_payment does not benefit the attacker — the transfer always goes to the declared merchant, not to the transaction submitter.
Limitation: Both the frontend and the backend depend on Soroban RPC nodes to read chain state and broadcast transactions. A malicious or censoring RPC could:
- Return stale event data to the backend indexer.
- Drop broadcast transactions.
- Return incorrect simulation results.
Mitigation:
- Use multiple RPC endpoints in the backend (primary + fallback).
- The contract enforces all critical invariants on-chain — a lying RPC cannot forge payment events that did not occur.
- For critical operations, verify transaction inclusion by querying the ledger directly.
Limitation: If a subscription entry's TTL expires (after ~365 days of no successful payments), the entry is garbage-collected by the Soroban host. A subsequent execute_payment call returns NoActiveSubscription. This can surprise merchants who expect stale subscriptions to be collectable indefinitely.
Mitigation: The backend Reconciler detects subscriptions that have not had a successful payment in an extended period and can alert merchants to re-subscribe or write off the account. See docs/architecture.md.
Limitation: The execute_payment entry point only requires a single signature from merchant. A compromised merchant key allows an attacker to collect all due payments for that merchant's subscribers.
Mitigation: Use a Stellar multisig account as the merchant address for production deployments:
# Set merchant account to require 2-of-3 signatures
stellar account merge --network mainnet ...Limitation: There is no contract-level mechanism to pause an individual subscription. The is_paused field exists in SubscriptionData but is not read by execute_payment.
Mitigation: Subscribers can achieve the equivalent of a pause by revoking their token allowance (approve(contract, 0)). Merchants can stop calling execute_payment. A future contract version may implement on-chain pause semantics.
Limitation: The backend, including the PaymentScheduler, is optional infrastructure. If it goes down, the contract continues to function correctly — subscriptions can still be collected by merchants calling execute_payment directly. However, the merchant dashboard, payout summaries, and webhook notifications will be unavailable.
Mitigation: Use health monitoring (e.g., Uptime Robot on /health) and configure alerting for indexer lag. See docs/architecture.md for HA deployment options.
Audit Cargo dependencies with cargo audit:
cargo install cargo-audit
cd contracts/subscription
cargo auditFor CI, add to .github/workflows/ci.yml:
- name: Cargo audit
run: cargo audit
working-directory: contracts/subscriptionCheck for known advisories in the RustSec Advisory Database.
Key contract dependencies:
| Crate | Version | Purpose |
|---|---|---|
soroban-sdk |
pinned | Soroban environment, types, storage |
soroban-token-sdk (via token::Client) |
pinned | SEP-41 token client |
Pin all dependency versions in Cargo.toml (no open ranges). The contract's Cargo.lock is committed to ensure reproducible builds.
Audit npm dependencies:
# Backend
cd backend && npm audit
# Frontend
cd frontend && npm auditFor CI:
- name: npm audit (backend)
run: npm audit --audit-level=high
working-directory: backend
- name: npm audit (frontend)
run: npm audit --audit-level=high
working-directory: frontendUse npm audit fix cautiously — prefer reviewing and pinning exact versions manually for security-sensitive packages.
Enable Dependabot in .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: npm
directory: /backend
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: cargo
directory: /contracts/subscription
schedule:
interval: weekly
open-pull-requests-limit: 3- Do not use
latesttags in Docker images — pin to a specific digest. - Verify Stellar SDK releases against the js-stellar-sdk changelog before upgrading.
- For the contract, always review
soroban-sdkrelease notes before bumping — breaking changes to host function interfaces can silently alter contract behaviour.
SorobanPay uses coordinated disclosure. If you find a security vulnerability:
- Do not open a public GitHub issue.
- Send a description of the vulnerability to the maintainers via GitHub Security Advisories:
- Go to the repository → Security → Advisories → New Draft Advisory.
- Include: affected component, description, reproduction steps, potential impact, and any suggested mitigations.
- You will receive acknowledgement within 72 hours.
- We will work with you to understand and validate the issue, and coordinate a disclosure timeline of 90 days from initial report (shorter for critical issues).
- Once a fix is released, we will credit you (with your consent) in the release notes and advisory.
For questions about this policy, contact the repository maintainers via the GitHub Discussions tab.
-
.envand secret files excluded from version control - No real values in
backend/.env.example - Production secrets stored in a secret manager or platform dashboard
-
DATABASE_URLrotated at least annually (or on any suspected compromise) -
WEBHOOK_SECRETset to a random 32-byte hex value -
OPERATOR_SECRETomitted unlessPaymentScheduleris actively needed - Pre-commit hook or
gitleaksconfigured to prevent accidental secret commits -
cargo auditandnpm auditpassing in CI - CSP headers configured in
next.config.mjs - Security advisory channel tested (can create a draft advisory)