Security · Mechanisms, with their real values

Security & compliance, mechanism by mechanism.

Every control on this page is one you can hold us to: the hash a credential is stored under, the header a response carries, the port a database does not listen on. Where a mechanism has a number, the number is printed.

HSTS max-age 31536000Keys stored as HMAC-SHA256No card data on our servers
tickatlas.com · security posture live config
Developer API keys Stored as a keyed digest, never as the key
HMAC-SHA256
Account passwords Adaptive hash with a per-password salt
bcrypt · cost 12
Dashboard session Opaque random token; only its digest is stored
384-bit · HttpOnly
Transport Plain HTTP is redirected, never served
TLS 1.2 / 1.3
Card details Entered and managed on Stripe-hosted pages
never on our servers
Database and cache No public listener; the app has no TCP port at all
127.0.0.1 only

Each row is a value read out of the running configuration or the schema, not a category we aspire to.

TLS 1.2+Lowest version offered
365 daysHSTS max-age
bcrypt 12Password cost factor
0Card numbers stored
Transport

What every response actually carries.

Plain HTTP is answered with a permanent redirect to HTTPS and never served. The TLS handshake offers TLS 1.2 and TLS 1.3 only, with authenticated-encryption cipher suites and session tickets switched off. On top of that, every response leaves with the header set below.

Header Value What it prevents
Strict-Transport-Security max-age=31536000; includeSubDomains; preload A browser that has seen one response refuses plain HTTP to this host and its subdomains for the next 365 days.
Content-Security-Policy default-src 'self'; … Script, style, font, image, frame and connect sources are restricted to an explicit allow-list rather than "anywhere".
X-Frame-Options DENY No page can be framed, so a hostile site cannot overlay a transparent frame over a real control.
X-Content-Type-Options nosniff The declared content type is authoritative; the browser will not re-interpret a response as script.
Referrer-Policy strict-origin-when-cross-origin An outbound click leaks the origin, never the full path and query string of the page you were on.
Permissions-Policy geolocation=(), camera=(), microphone=(), payment=() Geolocation, camera, microphone and the Payment Request API are switched off for every document.

These are set at the web-server layer and re-applied in every location that defines headers of its own, because a response that inherits nothing is the one an audit finds.

API keys

Your key is a bearer credential. It is treated like one.

A key in a header is all it takes to spend your quota, so the interesting question is not whether we hash it but what an attacker gets from each place it could leak — the database, the dashboard, a log, or a request from the wrong network. Six controls answer that, and each one is a column you can see in your own key list.

  • Send it in the X-API-Key header — never in a query string, where it lands in logs and referrers.
  • Mint a separate key per environment, so revoking one does not take production down.
  • Set the IP allow-list on the key your servers use; leave it open only where addresses are not stable.
api_keyswhat is stored per key
# created once, returned once
key      = "tk_" + secrets.token_urlsafe(32)   # 256 bits

# what the row keeps
key_hash     = hmac_sha256(SECRET_KEY, key)  # not the key
key_prefix   = key[:12]                      # so you can tell keys apart
permissions  = { "quotes": true, "indicators": true }
ip_whitelist = "203.0.113.7, 198.51.100.0/24"
expires_at   = "2027-01-01T00:00:00Z"
is_active    = true

# on every request
lookup(key_hash) -> is_active? -> not expired?
  -> ip in whitelist? -> permission for this endpoint?
Key controls

Six controls, six columns.

Nothing here is a setting we hold on your behalf and describe in prose. Each one is readable and changeable from your own key list, and each is enforced on the request path rather than at sign-in.

Generation

256 bits from the OS random source

A key is the prefix tk_ followed by 32 random bytes drawn from the operating system CSPRNG. It is not derived from your account, your email or a counter, so one key tells an attacker nothing about another.

tk_ + secrets.token_urlsafe(32)

Storage

The key itself is not in the database

What is stored is an HMAC-SHA256 digest computed with a server-side secret, plus the first twelve characters so you can tell your keys apart in the dashboard. A database dump on its own does not yield a usable key.

key_hash · key_prefix

Display

Shown in full exactly once

The full key is returned by the create and regenerate calls and never again. No dashboard route and no support request can retrieve it, because nothing retains it. If you lose it, regenerate it.

"Only shown once"

Scope

Permissions per endpoint group

Each key carries a permissions document naming the endpoint groups it may reach. A key minted for quotes cannot be replayed against the endpoints you did not grant it.

permissions

Binding

IP and CIDR allow-lists, failing closed

A key can be pinned to specific addresses or CIDR ranges. A request from outside the list is refused — and when a list is set but the client address cannot be resolved, the request is refused rather than allowed through.

ip_whitelist

Lifecycle

Expiry, suspension, deletion

A key can carry an expiry date, be suspended without being destroyed, or be deleted outright. Expiry and suspension are checked on the request path, so the change takes effect on the next call rather than at the next login.

expires_at · is_active

Account authentication

The dashboard login, with its real settings.

Your dashboard account is the thing that can mint keys and change billing, so it is the more valuable target. These are the settings in force on it — the hash, the session bounds, the cookie flags and the two brute-force limits.

Mechanism In force Why it is set that way
Password hashing bcrypt, cost factor 12 Per-password salt and a deliberately slow verify, so an offline guess costs real time rather than a hash lookup.
Unknown-email login Same bcrypt work A login for an address that does not exist still performs a full verification, so response time does not reveal which emails are registered.
Session token 384-bit random, digest stored The cookie holds an opaque random token; the server stores only its SHA-256, so a read of the session table does not yield a usable cookie.
Session cookie flags HttpOnly · SameSite=Lax Script cannot read it, and it is marked Secure on HTTPS connections. The CSRF token cookie is SameSite=Strict.
Session lifetime 7 days absolute · 8 hours idle Both bounds are enforced server-side on every authenticated request, not by cookie expiry alone.
Two-factor auth TOTP, optional An authenticator app plus one-time backup codes. With 2FA on, a correct password yields a short-lived pending token and no session until the code is accepted.
Login attempts per IP 5 per minute Enforced fail-closed: if the limiter backend is unavailable the login is refused rather than allowed without a limit.
Per-account lockout 10 failures · 15 minutes Counted per account rather than per address, so spreading the guesses across many IPs does not evade it. The lock releases itself.
State-changing requests CSRF token required Middleware requires a matching token header on every non-GET request; GET, HEAD, OPTIONS and TRACE are exempt by design.
Abuse controls

Two layers of throttling, and the numbers in each.

At the edge, the web server caps traffic at 100 requests per second per client address with a short burst allowance — that layer does not know or care who you are. Inside the application, your plan's rate limit is applied over a rolling 60-second window, and the daily quota over the day. Exceeding either returns 429 with X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After, so a client can back off from the response instead of guessing.

Plan Rate limit Requests / day API keys
Pay as you go 30/min 200 10
Starter 120/min 10,000 3
Pro 600/min 100,000 10
Enterprise 6000/min 1,000,000 100

Rate limits and daily quotas are generated from the enforcement configuration rather than typed into this page, so a limit change cannot leave the published figure behind.

Balance integrity

Your balance is a ledger, not a number someone can edit.

A prepaid balance held as a single mutable column is one bad update away from being wrong with no way to prove it. So the balance is the sum of an append-only ledger, and the column you see is a cache of that sum, rewritten inside the same database transaction as every entry.

01

Append-only by construction

A database trigger rejects any attempt to update or delete a ledger row. There is no code path, admin screen or script that can quietly rewrite history.

02

One entry per economic event

Every entry carries a unique idempotency key. A payment webhook redelivered three times inserts one row, so a replay cannot double-credit or double-charge.

03

Corrections are entries too

A wrong entry is fixed with a compensating entry of the opposite sign. The mistake and the correction both stay visible, which is what makes the balance auditable.

04

Atomic with the thing it pays for

The debit and the work it pays for commit together or not at all — you are not charged for a call that failed to complete, and a completed call is not free.

credit_ledgerappend-only
# balance = SUM(amount) for your user id
{ "mutation_type": "signup_grant",  "amount": 2.5000,
  "idempotency_key": "signup:10422" }
{ "mutation_type": "topup",         "amount": 25.0000,
  "idempotency_key": "order:9f31c0" }
{ "mutation_type": "invoice_debit", "amount": -4.1200,
  "idempotency_key": "invoice:7781" }

# a redelivered webhook: ON CONFLICT DO NOTHING
INSERT ... "idempotency_key": "order:9f31c0"  -> 0 rows

# and an edit is simply refused
UPDATE credit_ledger SET amount = ...
-- ERROR: credit_ledger rows are immutable
What reaches our servers

The cheapest way to protect data is not to hold it.

Two categories are worth being explicit about, because they are the two people ask about: payment instruments, which we do not receive at all, and your request history, which we do keep and hand back to you.

Card payments

Card details are entered on Stripe, not here

Checkout and card management run on Stripe-hosted pages. Our schema has no column for a card number, an expiry date or a security code — what it holds is the Stripe customer, subscription, payment-intent and invoice identifiers, the amount in cents, the currency and the status, which is what a receipt and a plan change need.

stripe_customer_id · amount_cents · status

Crypto payments

Order records, not wallet credentials

Crypto top-ups run through a payment processor. We record the order reference, the deposit address the processor issued, the amounts on both sides and the settlement status. No wallet key or seed is ever transmitted to us, because none is needed to observe that an invoice settled.

order_id · pay_currency · status

Request history

Recorded, and yours to read

Each authenticated request is logged with the endpoint, method, query parameters, response code, response time, client address and user agent. That is what makes your own usage auditable: the dashboard lets you filter it by endpoint, status class and key, and export it as CSV. It is retained for the life of the account.

endpoint · response_code · ip_address

Credentials

Stored as digests, not as values

Passwords are bcrypt hashes. API keys are keyed HMAC-SHA256 digests. Session tokens are stored as their SHA-256. None of the three is recoverable from what the database holds, which is why a lost key is regenerated rather than looked up.

password_hash · key_hash · session digest

Isolation

One process is reachable from the internet.

Most of what makes a deployment hard to attack is what it declines to expose. The database, the cache and the application server are not on the network at all; the web server is, and it serves a directory that contains no application code.

Listeners

One public process

PostgreSQL and Redis are bound to 127.0.0.1. The application server does not listen on a TCP port at all — nginx reaches it over a unix domain socket, so the web server is the only process with a public listener.

Document root

The source tree is not served

nginx serves the built static site, not the application directory. The Python package, the configuration and the dependency manifest are outside the document root and are not reachable over HTTP by any path.

Process

Unprivileged and confined

The service runs as an unprivileged user under systemd with a read-only view of the filesystem apart from two writable paths, a private temporary directory, and no ability to acquire new privileges.

Edge

Client address that cannot be forged

The site sits behind a CDN proxy, and the real client address is taken from its header only for connections arriving from that provider’s published ranges. A direct-to-origin client cannot set that header and be believed.

Before a change ships

Two scanners, twice each.

Every commit runs a static security analyser over the application package and a secret scanner over the diff — once as a pre-commit hook on the developer's machine, and again in continuous integration so a bypassed hook does not get a free pass. The same run executes the test suite and a contract check that fails the build when the documentation states a limit the code does not implement.

That contract check is why the numbers on this site's pages are generated, not typed.
When something breaks

Runbooks live beside the code.

A written disaster-recovery and incident runbook and an on-call reference are maintained in the repository alongside the application, covering service control, health checks, log triage and database restore. Liveness and readiness endpoints report the state of the database, the cache and the background workers.

Current availability is published separately, and the status page is the source of truth for it — not this page, which describes design rather than today.

Security summary

The published control summary.

The four groups below are this page's standing summary of infrastructure, API, data and operational controls. Each line is a control that exists today; a control we do not have is absent from this list rather than softened into one.

Infrastructure security

TLS Encryption

All API traffic encrypted with TLS 1.2 or TLS 1.3. HSTS enforced on all endpoints.

Edge Proxy & Rate Limiting

Traffic reaches the application through a CDN proxy, and the web server applies a per-client request rate limit in front of it.

Network Isolation

Database and internal services are not directly accessible from the internet.

Regular Updates

Operating-system security updates are applied automatically. Every code change is scanned for security defects and for committed secrets.

API security

API Key Authentication

All requests authenticated via API keys with configurable permissions and optional IP whitelisting.

Rate Limiting

Per-key rate limits prevent abuse and ensure fair usage across all customers.

Key Management

Create, rotate, and revoke API keys at any time. Set expiration dates and restrict to specific endpoints.

Request Logging

Full audit trail of API access. Monitor usage patterns and detect anomalies via your dashboard.

Data protection

Credential Storage

API keys are stored as an HMAC-SHA256 digest keyed by a server-side secret, passwords as bcrypt hashes, and session tokens as SHA-256 digests. None of the three is recoverable from what the database holds.

Database Security

PostgreSQL accepts connections from the local host only. Backups run automatically on a daily schedule, with a fixed retention window and a daily off-site copy.

Minimal Data Collection

We only collect data necessary to provide the service. See our Privacy Policy.

Operational security

Monitoring & Alerting

External uptime probes run continuously against a public health endpoint, and scheduled checks raise an alert on ingest and data-freshness failures.

Incident Response

A written incident and disaster-recovery runbook is maintained alongside the application, covering service control, health checks, log triage and database restore.

Backup & Recovery

Automated daily database backups with a fixed retention window and a daily off-site copy. The restore procedure is documented in the recovery runbook.

Report a vulnerability

Tell us privately, and tell us precisely.

We take security seriously. If you discover a vulnerability, please report it to [email protected]. We appreciate responsible disclosure and will acknowledge your report within 24 hours.

  • Send the smallest reproduction you have — the request, the response, and what you expected instead.
  • Test only against your own account and your own keys.
  • Please do not run load or denial-of-service tests, and do not exfiltrate data to demonstrate access.
  • Give us a chance to fix it before publishing, and we will credit you if you want to be credited.
Security reports Suspected vulnerabilities, exposed credentials, anything you would rather not put in a public issue. [email protected]
Acknowledged in 24h
Account and integration problems A key that will not authenticate or a status code you did not expect is support, not disclosure. [email protected]
See /contact
Data-protection requests Access, correction, export and erasure routes are documented on the GDPR page.
See /gdpr
Key handling in practice Header format, scopes, IP allow-lists, rotation and expiry, with the request examples. Authentication guide
Documentation
Frequently asked questions

Security, answered plainly.

The questions that come in most often: what happens to a lost key, what we can see of your payment details, what stops a replayed webhook, and where a certification question belongs.

Can you recover an API key I have lost?

No, and that is the point. The database holds a keyed HMAC-SHA256 digest of the key plus its first twelve characters — not the key. Nothing in the dashboard, the admin tooling or the support inbox can reconstruct it. Regenerate the key and update your deployment; the old one stops working immediately.

Do you ever see my card number?

No. Card entry and card management happen on Stripe-hosted pages — a Stripe Checkout session and the Stripe Customer Portal. What our database holds is the Stripe customer, subscription, payment-intent and invoice identifiers, the amount in cents, the currency and the status. There is no column anywhere in the schema for a card number, an expiry or a security code.

What stops one API key from reaching endpoints it was not meant to?

Each key carries a permissions document naming the endpoint groups it may use, and that is checked on the request path rather than in the dashboard. A key can additionally be pinned to an IP address or CIDR range, and be given an expiry date. Scope narrowly and mint separate keys for development and production.

What happens if I keep getting the password wrong?

Two independent limits apply. Five login attempts per minute from one address, enforced so that an unavailable limiter refuses logins rather than removing the limit. And ten failures against one account inside the window locks that account for fifteen minutes regardless of how many addresses the attempts came from. The lock expires on its own; no support ticket is needed.

Is two-factor authentication available?

Yes, optional TOTP with an authenticator app plus one-time backup codes. When it is enabled, a correct password alone does not create a session — it produces a short-lived pending token, and the session is only issued once a valid code or backup code is accepted.

Can I audit what my keys did?

Yes. Every authenticated request is recorded with the endpoint, method, query parameters, response code, response time, client address and user agent. The dashboard lets you browse and filter that history by endpoint, status class and key, and export it as CSV — so you can reconcile a bill or spot an unfamiliar caller yourself rather than asking us to look.

How do you stop a redelivered payment webhook from being applied twice?

Credit movements are rows in an append-only ledger with a unique idempotency key, and a database trigger rejects any attempt to update or delete a row. A replayed payment event inserts nothing the second time, and a mistake is corrected with a compensating entry rather than by editing history. Your balance is the sum of that ledger.

Do you hold a security certification?

This page describes mechanisms, not certifications, and nothing on it should be read as an audit result. If a compliance programme is material to a contract you are negotiating, raise it with sales directly rather than inferring an answer from this page.

How do I make a data-protection request?

The GDPR page documents the routes for access, correction, export and erasure requests, and what each request needs to include. Data export is available to you directly from the dashboard as a JSON download.

I think I found a vulnerability. What now?

Email the security address below rather than opening a public issue or posting it. Include what you did, what happened, and the smallest reproduction you have. Please do not test against other accounts, degrade the service, or exfiltrate data to prove a point.

Scope the key, pin the address, set the expiry

Most of the hardening is yours to switch on.

Permissions, IP allow-lists, expiry dates, separate keys per environment and two-factor authentication on the account are all one screen away. The authentication guide walks through each of them with the request that proves it worked.