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
- Pause / Unpause Runbook (SC-30)
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.
Audit status: COMPLETE — every entry point listed below has been verified by code review and covered by negative unit tests in
contracts/subscription/src/security_tests.rs. The test categories referenced are in the same file.
| Entry point | Authenticating address | require_auth() position |
Failure mode if wrong party | Test category |
|---|---|---|---|---|
initialize |
(none — panics if already initialized) | n/a — no auth | Panics: "already initialized" | — |
get_version |
(none — read-only) | n/a | No state change | — |
get_schema_version |
(none — read-only) | n/a | No state change | — |
migrate |
admin |
Line 1, before any storage read | require_auth() panics; or NotAdmin if wrong address provides correct auth |
Category 9 |
set_protocol_fee |
admin |
Line 1, before any storage read | require_auth() panics; or NotAdmin if wrong address provides correct auth |
Category 9 |
get_protocol_fee |
(none — read-only) | n/a | No state change | — |
compute_subscription_key |
(none — read-only) | n/a | No state change | — |
get_merchant_subscription_keys |
(none — read-only) | n/a | No state change | — |
subscribe |
subscriber |
Line 1, before input validation | require_auth() panics; merchant cannot create subscriptions without user's key |
Categories 1, 2, 3, 6 |
execute_payment |
merchant |
Line 1, before storage load | require_auth() panics; subscriber/attacker cannot trigger token transfer |
Categories 1, 2, 3, 6, 7 |
batch_execute_payment |
merchant |
Line 1, before loop | require_auth() panics; only the declared merchant can batch-collect |
Category 10 |
cancel |
subscriber |
Line 1, before storage check | require_auth() panics; merchant cannot cancel subscriber's agreement |
Categories 1, 2, 3, 6 |
transfer_subscription |
subscriber AND old_merchant (dual-auth) |
Lines 1–2, before storage load | Either require_auth() panics; neither party alone can reassign |
Category 11 |
get_subscription |
(none — read-only) | n/a | No state change | — |
get_subscription_count |
(none — read-only) | n/a | No state change | — |
Soroban's require_auth() is stateless: it checks the authorization envelope of the current invocation only. There is no session, no ambient grant, and no way for a previous invocation's authorization to carry over to a subsequent call. This is enforced by the host, not application logic.
The following tests in Category 12 (security_tests.rs) verify this property explicitly:
sec_no_ambient_auth_from_subscribe_to_execute_payment— a priorsubscribeauthorization does not permit a subsequent unauthorizedexecute_payment.sec_no_ambient_auth_from_execute_payment_to_cancel— a priorexecute_paymentauthorization does not permit a subsequent unauthorizedcancel.sec_no_ambient_auth_across_two_subscribe_calls— eachsubscribecall requires its own fresh signature.
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.
transfer_subscription moves a subscription from old_merchant to new_merchant. Both subscriber and old_merchant must authorize because:
- The subscriber is consenting to a change in who receives their payments.
- The old merchant is consenting to give up their payment stream (preventing unilateral hijack by the subscriber alone).
Neither party alone can reassign the subscription. An attacker holding neither key cannot forge either signature.
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();
// ...
}| # | Check | Status |
|---|---|---|
| 1 | Every mutating entry point calls require_auth() as its first statement |
✅ Verified by code review |
| 2 | Read-only entry points (get_*, compute_*) require no auth |
✅ Verified |
| 3 | subscribe authorizes subscriber, not merchant |
✅ Tests: Category 3, 6 |
| 4 | execute_payment authorizes merchant, not subscriber |
✅ Tests: Category 3, 6 |
| 5 | batch_execute_payment authorizes merchant — once for the batch |
✅ Tests: Category 10 |
| 6 | cancel authorizes subscriber, not merchant |
✅ Tests: Category 3, 6 |
| 7 | transfer_subscription requires both subscriber and old_merchant |
✅ Tests: Category 11 |
| 8 | migrate authorizes admin (stored on-chain at initialize) |
✅ Tests: Category 9 |
| 9 | set_protocol_fee authorizes admin |
✅ Tests: Category 9 |
| 10 | No entry point reads auth state set by a previous invocation (no ambient auth) | ✅ Tests: Category 12 |
| 11 | Attacker with no authorization receives panic on every mutating entry point | ✅ Tests: Category 2 |
| 12 | Replay within same billing window returns PaymentNotDue |
✅ Tests: Category 4 |
| 13 | Cancellation prevents subsequent payment collection | ✅ Tests: Category 4 |
| 14 | Self-subscription rejected before any storage write | ✅ Tests: Category 5 |
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.
All merchant-scoped backend API endpoints require authentication. Because merchants are Stellar keypair owners — not username/password holders — authentication is based on proving ownership of a Stellar private key via a cryptographic challenge-response flow. This mirrors SEP-10: Stellar Web Authentication.
Password-based authentication is inappropriate for a non-custodial blockchain app:
- Merchants have no passwords — they have Stellar keypairs.
- Issuing a JWT after a password check does nothing to verify the merchant controls the on-chain address that owns the subscription records.
- SEP-10 challenge-response ties authentication directly to key ownership, making it impossible to claim another merchant's data without their private key.
1. GET /api/v1/auth/challenge?account=G…
← { transaction: "<unsigned XDR>", network_passphrase, expires_in: 300 }
2. Merchant signs the transaction with their private key (e.g. via Freighter).
3. POST /api/v1/auth/token { transaction: "<signed XDR>" }
← { token: "<JWT>", expires_in: 86400 }
4. All subsequent requests:
Authorization: Bearer <JWT>
The challenge is a Stellar ManageData transaction:
- Source account: the merchant's G-address (so the transaction is tied to their key).
- Operation:
ManageData("SorobanPay auth", <32-byte random nonce>). - TTL: 5 minutes. The server rejects signed transactions after expiry.
- Never broadcast: the transaction is only used as a sign-this-data vehicle; it is never submitted to the network.
Before issuing a JWT, the server checks:
- The XDR decodes to a valid Stellar transaction.
- A pending challenge exists for the transaction's source account.
- The challenge has not expired.
- The
ManageDatanonce in the transaction matches the stored nonce (prevents replay of a different account's signed XDR). - At least one signature on the transaction is valid for the source account, verified using
Keypair.verify()from@stellar/stellar-sdk.
On success the challenge is consumed (deleted from the in-memory store) so it cannot be replayed.
Signed with HMAC-SHA256 using JWT_SECRET from the environment. The algorithm and implementation match the admin JWT (adminAuth.ts) to keep the codebase consistent.
| Route prefix | Protection |
|---|---|
GET /api/v1/auth/challenge |
None (public — required to start the flow) |
POST /api/v1/auth/token |
None (public — accepts signed challenge) |
GET /api/v1/subscriptions/* |
requireMerchant — valid JWT required |
All other /api/v1/ routes |
Unchanged (see their respective middleware) |
| Variable | Required | Notes |
|---|---|---|
JWT_SECRET |
✅ | 32+ byte random string; generate with openssl rand -hex 32. Never reuse ADMIN_JWT_SECRET. |
requireMerchant extracts res.locals.merchantAddress from the JWT. Route handlers that return merchant-specific data (e.g. subscriptions, payments) must filter by this address — not by a URL parameter — to prevent horizontal privilege escalation. See docs/backend-tenant-isolation.md for the full tenant isolation guide.
- In-memory challenge store: pending challenges are stored in a
Mapin process memory. In a multi-process (horizontally scaled) deployment this store must be moved to Redis or another shared cache. - No account existence check: the server accepts any syntactically valid G-address for the challenge step. A non-existent account will simply never receive a JWT because the merchant won't have a valid signing key.
- Nonce is per-account: only one pending challenge exists per account at a time. A new challenge request supersedes the previous one; the old challenge becomes invalid.
- SEP-10 specification
backend/src/services/authService.ts— core challenge/JWT logicbackend/src/routes/auth.ts— HTTP endpointsbackend/src/middleware/merchantAuth.ts— JWT guard middlewarebackend/tests/auth.test.ts— 34 unit tests
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 ...The contract now supports a protocol-wide pause via pause_contract / unpause_contract (see §9 Pause / Unpause Runbook). This halts all state-mutating entry points simultaneously.
Remaining limitation: There is no mechanism to pause an individual subscription. The is_paused field exists in SubscriptionData but is not read by execute_payment.
Mitigation for individual pauses: Subscribers can achieve the equivalent by revoking their token allowance (approve(contract, 0)). Merchants can stop calling execute_payment for specific subscribers.
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)
- Auth audit checklist completed (§2 Authorization Audit)
- Negative auth tests for every entry point (
security_tests.rs, categories 1–12) - No entry point relies on ambient auth state (category 12 tests pass)
- Auth tests run as part of
make test
{ "address": "G…", // Merchant's Stellar public key — the authenticated identity "iat": 1700000000, // Issued-at (Unix seconds) "exp": 1700086400 // Expiry 24 hours later }