How TillPulse handles your data.
A developer-facing rundown of the security primitives in the platform. For our customer-facing security commitments, see the public security page.
Authentication
- Passwords hashed with
bcryptjs(12 rounds). - Access token: JWT RS256 (jose), 15-minute TTL, signed with
JWT_PRIVATE_KEY. - Refresh token: opaque 32-byte base64url string, only sha256 stored in
auth_tokens, 30-day TTL, single-use, rotated on every refresh. - Both tokens delivered as httpOnly cookies (
tp_access,tp_refresh) withSameSite=Lax. - Logout revokes the refresh row; subsequent refresh attempts return 401.
- Password reset invalidates all active refresh tokens for that user — sign-out everywhere.
Two-factor authentication
TOTP (RFC 6238). Secrets encrypted at rest with AES-256-GCM using TOTP_ENCRYPTION_KEY. Backup codes are sha256-hashed; only prefixes shown back to the user.
SSO via OIDC
Per-org issuer + client ID + client secret + allowed-email-domains list. Authorisation flow uses PKCE; state + code_verifierare round-tripped through a 10-minute signed cookie. JIT user creation gated by domain match. id_token verified against the issuer's JWKS via createRemoteJWKSet. Client secrets stored AES-256-GCM-encrypted with ENCRYPTION_KEY.
API keys
Personal API keys: tp_sk_<region>_<random>. Shown once on creation; only key_prefix + sha256 of the rest stored. Revoke is an immediate DB delete + the next request hits the cache TTL ≤ 60s.
Multi-tenancy
Every resource is scoped to org_id. requireSession()returns { sub, org, role, email } from the signed JWT; queries filter by org_id derived from session.org. Role hierarchy: owner > admin > developer > viewer. Write operations check ['owner', 'admin'].includes(session.role).
PII scrubbing
Two-pass scrub. Patterns matched in both:
- Email addresses
- Phone numbers (NG, KE, ZA, GH, RW formats)
- Payment card numbers (Luhn-checked)
- NIN / BVN
- M-Pesa transaction references
- Bearer tokens in
Authorizationheaders - IPv4 addresses in payloads
- SDK: Before transport. Configurable via
beforeSend. - Ingest API: Before publishing to the Redis Stream. Server can never trust client.
Rate limiting
Per-DSN sliding-window rate limiting. Per-IP ZSET as a fallback against DSN-spray. Both honoured by the ingest API; 429 returns include Retry-After.
Webhook signature verification
- GitHub:
X-Hub-Signature-256, timing-safe HMAC against per-project secret. Raw body required (re-serialising breaks the HMAC). - Slack interactive:
X-Slack-Signature+X-Slack-Request-Timestamp; basestring isv0:{ts}:{rawBody}; 5-minute replay window. - Linear:
Linear-Signature, HMAC-SHA256. - Jira: Shared
?token=query parameter (Jira REST webhooks are unsigned). - Slack action buttons: JWT (HS256, 24h,
SLACK_ACTION_SIGNING_SECRET) embedded in buttonvalue; verified before applying any state change.
Encryption at rest
AES-256-GCM for: TOTP secrets, SSO client secrets, tracker integration tokens. Format iv:tag:ct (all hex). Key from ENCRYPTION_KEY(or TOTP_ENCRYPTION_KEY for the TOTP table — they are namespaced for blast-radius reasons).
Audit log
Every privileged action writes a row to audit_log: actor, action, resource, IP, user-agent, created_at. Visible at Settings → Audit log for owners and admins. Retained 365 days.
Threat intel
Cross-customer hashed indicators (sha256 of the indicator value — never the plaintext). An indicator is surfaced once it is confirmed malicious, corroborated across multiple organizations, or seen locally.
Reporting a vulnerability
Email security@tillpulse.io. We respond within one business day. Please do not file public issues for vulnerabilities. Coordinated disclosure preferred. See the public security page for our full statement.