Skip to main content

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

TokenWhere StoredTTLNotes
Supabase access token (JWT)JS memory or secure session storeShort-livedValidated against the Supabase JWKS
Supabase refresh tokenSupabase client / secure cookie storeRotated on useNever persist in localStorage
Admin session policySupabase session + app-side checkShorter app-side windowRequire 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: Bearer headers — 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: XMLHttpRequest header or a CSRF token.
  • The SameSite=Strict attribute 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, $2 placeholders.
// 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 ClassAccess RestrictionAt-Rest EncryptionRetention
Phone numbersHashed for lookup; only consumer and admin can view plaintextYes (AES-256 via DB-level encryption)Duration of account
Transaction amountsTenant + adminNo (not PII but business-sensitive)7 years
OTP codesNever logged; handled by Supabase AuthN/ANot stored in SalesArck
POS OAuth tokensEncrypted at rest; accessible by system onlyYes (AES-256)Active while connected
Wallet balancesConsumer (own only), merchant, adminNoDuration 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

EndpointLimitWindow
Supabase Auth OTP request5 per phone10 minutes
Supabase Auth OTP verify5 attempts per OTPPer OTP lifetime
Session refreshClient-managed by SupabaseN/A
All authenticated endpoints200 per user1 minute
Admin endpoints500 per admin1 minute
Unauthenticated endpoints50 per IP1 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