stile
Guides

Security Best Practices

Webhook-first access control, signature verification, key management, rate limits, and anti-fraud measures for production integrations.

A verification result is only as trustworthy as the channel that delivers it. This guide covers the practices that keep your integration secure in production — webhook-first access control, signature verification, API key hygiene, and rate-limit handling — plus how Stile's design minimizes the PII you ever touch.

Webhooks are the source of truth

The client-side stile:verified event is a convenience signal for updating your UI. It fires in the user's browser, which means anyone with dev tools open can fake it. Never grant access, unlock content, or fulfill orders based on client events alone.

Never trust the client

Always wait for the signed verification_session.verified webhook before granting access. The webhook is sent server-to-server over HTTPS and includes an HMAC-SHA256 signature that proves it came from Stile.

Live organizations enforce this pattern at the API level: you can't create sessions until at least one active webhook endpoint is registered (the API returns 400 webhook_required). Sandbox organizations are exempt, so you can prototype first and wire up webhooks before going live.

Webhook signature verification

Every delivery includes a Stile-Signature header. Verify it before processing the event — an unverified webhook endpoint is equivalent to no verification at all, because anyone can POST a fake payload to your URL.

RuleDetail
Header formatStile-Signature: t={timestamp},v1={signature}
AlgorithmHMAC-SHA256 over {timestamp}.{raw_body}, keyed with your endpoint secret
ComparisonTiming-safe — never use plain string equality
Replay protectionReject timestamps older than 5 minutes (300 seconds)
BodyAlways verify against the raw request body — parsing and re-serializing changes the bytes

With @stile/node this is one call — await stile.webhooks.fromRequest(req, secret) verifies the signature and parses the event in a single step, throwing WebhookSignatureError on any failure (respond with 400).

See the Webhook Signature Verification guide for the raw algorithm and copy-paste handlers in Node.js, Python, Go, Ruby, and PHP.

If an endpoint's signing secret may have leaked, rotate it with POST /v1/webhook_endpoints/:id/rotate-secret — the new secret is returned once in the response.

Key management

Stile uses two types of API keys:

Key typePrefixWhere to use
Secret keystile_sk_…Server-side only. Creates sessions, reads results, manages webhooks.
Publishable keystile_pk_…Safe for frontend code. Opens the verification widget. Cannot read results or manage resources.

Secret keys are displayed once at creation — store them immediately in your secrets manager or environment configuration, never in source control.

Never expose secret keys

Secret keys (stile_sk_) must never appear in frontend code, client-side bundles, mobile apps, or public repositories. If a secret key is compromised, rotate it immediately in the dashboard.

Key rotation — You can rotate keys in the dashboard without downtime. Both the old and new key remain valid during a configurable grace period, giving you time to update your servers.

Prefer backend session creation — Creating sessions with a publishable key from the browser is deprecated (responses carry a Stile-Deprecation: publishable-key-session-create header). Create sessions server-side with your secret key and hand the client_secret to the widget instead.

Rate limiting

Rate limits bound the blast radius of a leaked key or a runaway retry loop. API requests are rate-limited per key, per minute, on a rolling window: 1,000 requests/minute for secret keys, 100/minute for the browser-exposed publishable keys.

Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers so you can throttle proactively. When you exceed the limit, the API returns 429 Too Many Requests with a Retry-After header indicating how many seconds to wait before retrying.

if (res.status === 429) {
  const retryAfter = parseInt(res.headers.get("Retry-After") || "1", 10);
  await new Promise((r) => setTimeout(r, retryAfter * 1000));
  // retry the request
}

See Error Handling for full retry-with-backoff examples in multiple languages.

PII handling

Stile is designed to minimize your PII exposure:

  • Raw identity data is purged after verification — images, full names, dates of birth, and document numbers are not stored long-term.
  • Only hashed anchors persist — email hashes, phone hashes, and document fingerprints are retained for deduplication and returning-user lookup.
  • Verification outcomes persist — the age tier result (e.g. min_age_21), credential method, and expiry are stored so returning users can skip re-verification.

This means you never need to store or handle raw identity documents yourself. The verification result tells you what you need to know (age tier, pass/fail) without exposing the underlying PII.

Anti-spoofing measures

Stile employs multiple layers to prevent identity fraud:

MeasureWhat it catches
Barcode cross-referencePrinted photos of IDs — the barcode data must match the visual fields.
Head-turn liveness challengeFlat images and photos held up to the camera — the user must turn their head to prove they are physically present.
Server-side frame analysisSDK spoofing and injected video feeds — frames are analyzed server-side, not just on the client.
Face matchStolen or borrowed IDs — the selfie must match the photo on the document.
Document expiry checkExpired documents are rejected.

VPN and geolocation detection

If your use case involves physical delivery (e.g. alcohol, cannabis), pass the delivery_jurisdiction parameter when creating a session:

curl -X POST https://api.stile.id/v1/verification_sessions \
  -H "Authorization: Bearer stile_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "age",
    "workflow_id": "wf_YOUR_WORKFLOW_ID",
    "delivery_jurisdiction": "US-CA"
  }'

Stile compares the user's IP-based geolocation against the delivery jurisdiction. If there is a significant mismatch (e.g. the user's IP is in a different country), the session is flagged for review. When you pass delivery_jurisdiction, the create response also includes ip_jurisdiction and jurisdiction_mismatch fields so your backend can act on the comparison directly.

VPN detection is advisory

A geolocation mismatch does not automatically fail the session. It adds a flag to the verification result that your backend can use to apply additional checks or manual review.

Email OTP as ownership proof

When a returning user is looked up by email, they must complete an OTP challenge to prove they control that email address. This is an important distinction:

  • OTP proves: "This person controls this email address."
  • OTP does not prove: Age, identity, or any other verification claim.

The actual proof of age or identity comes from the underlying verification credential that was previously issued. The OTP simply gates access to that credential so a different person can't reuse it.

See Returning Users for the full reverification flow.

Security checklist

A summary of the do/don't items from this page:

AreaDoDon't
Access controlWait for the signed verification_session.verified webhook before granting accessUnlock content or fulfill orders on the client-side stile:verified event
Webhook handlerVerify Stile-Signature on every delivery, against the raw body, with a timing-safe compareProcess unverified payloads or verify against a re-serialized body
Replay defenseReject signatures whose timestamp is older than 5 minutesAccept old deliveries without checking the timestamp
API keysKeep stile_sk_ keys server-side; rotate immediately if exposedShip secret keys in frontend bundles, mobile apps, or repositories
Session creationCreate sessions server-side and pass the client_secret to the widgetRely on deprecated publishable-key session creation in production
Rate limitsHonor Retry-After on 429 and back offHammer the API in a tight retry loop
PIIRely on the verification result (age tier, pass/fail)Store raw identity documents yourself
GeolocationPass delivery_jurisdiction for physical delivery and review mismatch flagsAuto-fail sessions on a geolocation mismatch — it's advisory
Returning usersTreat OTP as proof of email control onlyTreat OTP success as proof of age or identity

Next steps

On this page