This document outlines security best practices for the DeFiWise backend API that handles admin-signed transactions.
NEVER:
- ❌ Commit
.envfiles to Git - ❌ Include the secret key in client-side code
- ❌ Log the secret key or expose it in error messages
- ❌ Send the secret key in API responses
- ❌ Store the secret key in localStorage or cookies
- ❌ Share the secret key in chat, email, or documentation
- ❌ Include the secret key in frontend bundle
ALWAYS:
- ✅ Store the secret key in
.env(local) or environment variables (production) - ✅ Verify
.envis in.gitignore - ✅ Use different keys for development and production
- ✅ Rotate keys periodically (especially if compromised)
- ✅ Keep
.env.examplewith placeholder values only - ✅ Use Vercel's environment variable encryption in production
Local Development:
# .env (NEVER commit this file)
ADMIN_SECRET_KEY=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXProduction (Vercel):
- Go to Project Settings → Environment Variables
- Add
ADMIN_SECRET_KEYas a secret - Ensure it's marked as sensitive (hidden by default)
- Use different keys per environment (Development, Preview, Production)
All API endpoints implement comprehensive validation:
✅ Public Key Validation
- Validates Stellar public key format
- Checks key starts with 'G' and is 56 characters
- Uses
StellarSdk.StrKey.decodeEd25519PublicKey()for format verification
✅ Type Validation
- Verifies all required fields are present
- Checks types (string, number, etc.)
- Validates ranges (e.g., score 0-100, correct ≤ total)
✅ Business Logic Validation
- Checks challenge/module not already completed
- Validates XP amounts are positive
- Ensures sensible quiz scores
Current implementation:
- In-memory rate limiter per user
- Default: 10 requests per minute per endpoint
- Returns 429 status when exceeded
For production, use a distributed rate limiter:
// Example with Upstash Redis
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "1 m"),
analytics: true,
});
const { success } = await ratelimit.limit(userPublicKey);
if (!success) {
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}✅ Simulation Before Signing
- All transactions are simulated before signing
- Catches errors before spending fees
- Validates contract state
✅ Duplicate Prevention
- Queries contract state before signing
- Prevents duplicate challenge completions
- Prevents duplicate badge minting
✅ Transaction Monitoring
- Polls for transaction completion (max 30 seconds)
- Returns transaction hash for tracking
- Logs failures for debugging
Validation Checks:
- ✅ User public key format
- ✅ Challenge ID is not empty
- ✅ correct ≤ total
- ✅ maxXp > 0
- ✅ Challenge not already completed
- ✅ Rate limit not exceeded
Security Flow:
Request → Validate Input → Rate Check → Duplicate Check
→ Build TX → Simulate → Sign → Submit → Poll → Response
Validation Checks:
- ✅ User public key format
- ✅ Module ID is not empty
- ✅ Module title is not empty
- ✅ xpEarned > 0
- ✅ quizScore between 0-100
- ✅ Badge not already minted for module
- ✅ Rate limit not exceeded
Security Flow:
Request → Validate Input → Rate Check → Duplicate Check
→ Build TX → Simulate → Sign → Submit → Poll → Response
DO log:
- ✅ Transaction hashes
- ✅ User public keys (these are public)
- ✅ Challenge/module IDs
- ✅ XP amounts and scores
- ✅ Error types and messages
- ✅ Rate limit violations
- ✅ Failed simulations
DO NOT log:
- ❌ Admin secret key
- ❌ Transaction signatures
- ❌ Any private keys
- ❌ User IP addresses (check GDPR compliance)
// Example: Track failed transactions
if (getResponse.status === "FAILED") {
console.error("Transaction failed:", {
hash: txHash,
userPublicKey,
challengeId,
error: getResponse.resultXdr?.toString(),
});
// Send to monitoring service (Sentry, Datadog, etc.)
Sentry.captureException(new Error("Transaction failed"), {
tags: { txHash, userPublicKey },
});
}-
Immediate Actions:
- Generate a new admin keypair
- Update environment variables everywhere
- Redeploy all services
-
Update Contracts:
- If contracts support admin transfer, call the admin transfer function
- Otherwise, may need to redeploy contracts with new admin
-
Notify Team:
- Document the incident
- Review how the compromise occurred
- Update security procedures
- Check logs for suspicious patterns
- Temporarily reduce rate limits
- Block suspicious user public keys if needed
- Implement additional validation
- Investigate how validation was bypassed
- Review validation logic
- Add additional checks
- Consider reverting affected transactions (if contract supports it)
Before deploying to production:
-
.envis in.gitignoreand not committed -
ADMIN_SECRET_KEYis set in Vercel environment variables - Secret key is different from development key
- Rate limiting is configured appropriately
- Error messages don't leak sensitive information
- Transaction simulation is working
- Duplicate prevention is working
- Input validation tests pass
- Admin account has sufficient XLM balance
- Monitoring/logging is set up
- Error tracking is configured (Sentry, etc.)
- API endpoints are tested with invalid inputs
- CORS is configured correctly (if needed)
For high-value operations, consider multi-signature:
// Contract example
pub fn reward_quiz(
env: Env,
user: Address,
// ... other params
admin_signatures: Vec<Signature>
) {
// Require N of M admin signatures
require_multi_sig(&env, &admin_signatures, 2, 3);
// ... rest of logic
}Validate quiz answers server-side:
// Store correct answers server-side
const QUIZ_ANSWERS = {
"defi-basics-01": ["B", "A", "C", "D", ...],
};
// Validate before signing
export async function POST(request: NextRequest) {
const { challengeId, userAnswers } = await request.json();
const correctAnswers = QUIZ_ANSWERS[challengeId];
if (!correctAnswers) {
return NextResponse.json({ error: "Invalid challenge" }, { status: 400 });
}
const correct = userAnswers.filter((ans, i) => ans === correctAnswers[i]).length;
const total = correctAnswers.length;
// Now sign transaction with verified score
// ...
}Add IP-based rate limiting in addition to user-based:
import { NextRequest } from "next/server";
function getRealIP(request: NextRequest): string {
return request.headers.get("x-forwarded-for")?.split(",")[0] ||
request.headers.get("x-real-ip") ||
"unknown";
}
// Rate limit by IP
const ip = getRealIP(request);
if (!checkRateLimit(`ip:${ip}`)) {
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}Have the frontend sign requests with the user's wallet:
// Frontend
const message = JSON.stringify({ challengeId, timestamp: Date.now() });
const signature = await walletSign(message);
await fetch("/api/reward-quiz", {
body: JSON.stringify({
userPublicKey,
challengeId,
signature,
// ... other params
}),
});
// Backend
// Verify the signature matches the user's public keyMonitor admin account balance:
// Regular check (cron job or monitoring)
const adminBalance = await server.getAccount(adminPublicKey);
const xlmBalance = Number(adminBalance.balances[0].balance);
if (xlmBalance < 1000) {
// Alert: Low balance
console.warn("Admin account low on XLM:", xlmBalance);
// Send alert to team
}If you discover a security vulnerability:
- DO NOT open a public issue
- Email the team privately: security@defiwise.example (update with real email)
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will respond within 48 hours and coordinate disclosure.
Remember: Security is an ongoing process, not a one-time setup. Regularly review and update security measures as the project evolves.