Passwords are hashed using bcryptjs with a salt factor of 10 before storage. Plain-text passwords are never stored or logged.
// lib/auth.ts
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);- Signed with HS256 using
JWT_SECRET - Expire after 1 hour
- Payload contains
userId,email, and optionallywalletAddress - Extracted from the
Authorization: Bearerheader on every protected request
- Stored in the database as UUID strings (not JWTs)
- Sent as an HttpOnly, Secure (production), SameSite=Lax cookie — inaccessible to JavaScript
- 30-day expiry
- Token rotation: the old token is deleted and a new one is issued atomically in a Prisma transaction on every refresh, preventing replay attacks
- All tokens for a user are revoked on logout via
revokeUserRefreshTokens
- Uses Ed25519 cryptography via
@stellar/stellar-sdk - Nonce is a UUID generated server-side and stored in the database
- Nonce is nullified immediately after successful verification (single-use)
- Prevents replay attacks: a captured signature cannot be reused
All endpoints are rate-limited using an in-memory sliding-window limiter:
- Auth endpoints: 10 requests per minute per IP/user
- All other endpoints: 60 requests per minute per IP/user
- Returns
429 Too Many RequestswithRetry-Afterheader when exceeded
Production note: The current rate limiter is in-memory and does not share state across multiple server instances. For multi-instance deployments, replace with a Redis-backed limiter (e.g., Upstash).
Every API route validates request bodies using Zod schemas before processing. Invalid requests return 400 Validation failed with field-level error details. This prevents malformed data from reaching the database.
Every protected route:
- Extracts and verifies the JWT
- Checks that the authenticated user has permission for the specific resource (e.g., must be a circle member to contribute, must be the organizer to add members)
Every request is assigned a UUID x-request-id by the middleware. This ID is attached to both the request and response headers, enabling log correlation for debugging.
All foreign keys use onDelete: Cascade. Deleting a user removes all their circles, contributions, votes, and tokens. Deleting a circle removes all its members, contributions, and proposals.
API responses never include the password field. Prisma select statements explicitly exclude it:
select: { id: true, email: true, firstName: true, lastName: true }
// password is never selectedPerformance indexes are defined on all foreign keys and frequently filtered fields (status, circleId, userId) to prevent slow queries that could be exploited for denial-of-service.
Every state-changing function requires address.require_auth() — the Soroban SDK enforces that the transaction is signed by the correct account.
Arithmetic uses checked_add and checked_sub where applicable to prevent integer overflow.
- Default max members: 50
- Hard cap: 100 (enforced at contract level, not just application level)
The organizer can call panic() to immediately freeze all operations. This is a last-resort mechanism for critical bugs or exploits.
Partial withdrawals enforce a 10% penalty at the contract level — it cannot be bypassed by calling the API directly.
| Risk | Severity | Notes |
|---|---|---|
| In-memory rate limiter | Medium | Does not work across multiple server instances. Replace with Redis for production |
| No email verification | Medium | Users can register with any email address. Email verification flow is not yet implemented |
| JWT stored in localStorage | Medium | Vulnerable to XSS. Consider moving to memory storage or HttpOnly cookies |
| No CSRF protection | Low | SameSite=Lax on the refresh token cookie provides partial protection |
| Smart contract not audited | High | Must be professionally audited before mainnet deployment |
| No contract upgrade mechanism | Medium | Contract cannot be upgraded after deployment. Bugs require redeployment and migration |
| Shuffle uses ledger sequence | Low | Rotation shuffle seed is predictable by validators. Acceptable for savings circles but not for high-stakes randomness |
- Change
JWT_SECRETto a cryptographically random 32-byte hex string - Verify
RESEND_API_KEYis set and FROM address uses a verified domain - Replace in-memory rate limiter with Redis/Upstash for multi-instance deployments
- Enable HTTPS and verify SSL certificate is valid
- Set
DATABASE_URLto a PostgreSQL instance with SSL enabled - Enable automated database backups
- Have the Soroban smart contract audited by a professional security firm
- Test all contract functions on testnet with real Freighter wallets
- Implement email verification for new user registrations
- Review all API routes for missing authorization checks
- Set up error monitoring (e.g., Sentry) to catch runtime exceptions
- Configure Content Security Policy headers
- Review and restrict CORS origins to your production domain
- Ensure no secrets are committed to the Git repository
- Rotate all secrets if any were ever accidentally committed
Do not open public GitHub issues for security vulnerabilities. Instead, contact the maintainers directly via the email listed in the repository. Include:
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fix