Security
Transport Security
All traffic is encrypted in transit. No exceptions.
- External traffic: TLS 1.2 minimum, TLS 1.3 preferred. Managed by the load balancer or API gateway.
- Internal service-to-service: mTLS if running in a service mesh, otherwise TLS with certificate pinning.
- No HTTP to HTTPS redirects in production — HSTS enforced, HTTP traffic rejected.
Security Headers
Every response includes:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=()
Authentication & Token Design
| Token | Where Stored | TTL | Notes |
|---|---|---|---|
| Supabase access token (JWT) | JS memory or secure session store | Short-lived | Validated against the Supabase JWKS |
| Supabase refresh token | Supabase client / secure cookie store | Rotated on use | Never persist in localStorage |
| Admin session policy | Supabase session + app-side check | Shorter app-side window | Require stronger assurance for privileged roles |
Supabase rotates refresh tokens on use. If a stale refresh token is replayed, the session is invalidated and the client must sign in again.
Secrets Management
- No secrets in code, environment definition files, or logs.
- All secrets stored in cloud secrets manager (AWS Secrets Manager / GCP Secret Manager) or the Supabase dashboard where appropriate.
- Applications access secrets via SDK at startup — not injected as env vars where possible.
- Secrets rotation schedule:
- Supabase service role key: 90 days
- POS OAuth client secrets: per provider rotation policy
- Database credentials: 60 days
- SMS delivery credentials: 90 days
RBAC Controls
Three roles with distinct permission scopes. Roles are resolved from SalesArck-owned records after validating the Supabase JWT — not trusted from the client.
// role-guard.middleware.ts
export function requireRole(...roles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ code: 'FORBIDDEN', message: 'Insufficient role' });
}
next();
};
}
For tenant-scoped resources, validate that the requesting user's tenantId matches the resource's tenantId:
export function requireTenantOwnership() {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
const resourceTenantId = req.params.tenantId ?? req.body.tenantId;
if (req.user.role === 'admin') return next(); // admins bypass
if (req.user.tenantId !== resourceTenantId) {
return res.status(403).json({ code: 'FORBIDDEN', message: 'Cross-tenant access denied' });
}
next();
};
}
Input Validation
Every endpoint validates all inputs with Zod schemas. Never trust client data.
const earnRequestSchema = z.object({
amount: z.number().positive().max(1_000_000), // cents; max $10,000 per transaction
externalTransactionId: z.string().uuid(),
currency: z.literal('USD'),
metadata: z.record(z.unknown()).optional(),
});
router.post('/earn', validateBody(earnRequestSchema), async (req, res) => {
// req.body is typed and validated
});
Key rules:
- Reject unknown fields (
z.object(...).strict()or strip them with.strip()) - Validate numeric ranges — negative amounts, extreme values
- Always sanitize strings that are stored and returned — prevent stored XSS
CSRF Protection
- The API is primarily consumed by JS clients with
Authorization: Bearerheaders — bearer token auth is inherently CSRF-safe for these endpoints. - Endpoints that rely on cookies only (the refresh token rotation endpoint) require an
X-Requested-With: XMLHttpRequestheader or a CSRF token. - The
SameSite=Strictattribute on cookies prevents most CSRF vectors.
SQL Injection Prevention
- All database access uses parameterized queries — no string concatenation in SQL.
- ORM (Prisma or Drizzle) enforces this by default.
- Raw queries are only used where necessary and always use
$1, $2placeholders.
// Safe - parameterized via Prisma
const wallet = await db.wallet.findUnique({ where: { id: walletId } });
// Safe - explicit parameterization if using raw
const result = await db.$queryRaw`SELECT * FROM wallets WHERE id = ${walletId}`;
PII Handling
| Data Class | Access Restriction | At-Rest Encryption | Retention |
|---|---|---|---|
| Phone numbers | Hashed for lookup; only consumer and admin can view plaintext | Yes (AES-256 via DB-level encryption) | Duration of account |
| Transaction amounts | Tenant + admin | No (not PII but business-sensitive) | 7 years |
| OTP codes | Never logged; handled by Supabase Auth | N/A | Not stored in SalesArck |
| POS OAuth tokens | Encrypted at rest; accessible by system only | Yes (AES-256) | Active while connected |
| Wallet balances | Consumer (own only), merchant, admin | No | Duration of account |
Webhook Security
Incoming POS webhooks are verified before processing:
function verifySquareSignature(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret)
.update(payload)
.digest('base64');
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
- All webhook endpoints require valid HMAC or signature verification.
- Reject requests with expired timestamps (more than 5 minutes old).
- Idempotency enforced — replays are silently acknowledged but not reprocessed.
Rate Limiting
| Endpoint | Limit | Window |
|---|---|---|
| Supabase Auth OTP request | 5 per phone | 10 minutes |
| Supabase Auth OTP verify | 5 attempts per OTP | Per OTP lifetime |
| Session refresh | Client-managed by Supabase | N/A |
| All authenticated endpoints | 200 per user | 1 minute |
| Admin endpoints | 500 per admin | 1 minute |
| Unauthenticated endpoints | 50 per IP | 1 minute |
Compliance Considerations
- PCI DSS Scope: SalesArck does not process, store, or transmit cardholder data. Card data stays at the POS → acquirer layer. SalesArck receives only the final transaction amount.
- TCPA: OTP SMS via Supabase Auth requires the consumer's phone number, used only for authentication. Not used for marketing without explicit opt-in.
- GDPR / CCPA: Right-to-deletion endpoints must zero out PII, anonymize wallet history, and revoke all sessions.
Written byDhruv Doshi