See the attack. Act on it.
GATillShield is the security layer of TillDev. It reads the security telemetry your TillPulse SDKs already collect — device-integrity failures, TLS-pinning breaks, Android overlay abuse, web and desktop tamper signals — scores each one, and puts it in front of a rule. When a rule’s trigger is crossed it takes one action automatically: raise an incident, quarantine the session, revoke the user’s TillAuth sessions, or fire an alert. And when you want to stop an attack before it lands, the same service enforces rules inline at your own edge or origin — see Inline enforcement. No new pipeline to stand up — it runs in the workspace you already have.
The mental model
Three moving parts, in order:
- Signals come in — security events reported by the SDKs, each with a type, a severity, and a confidence score. Some carry a cross-customer threat-intel match.
- Rules watch those signals. A rule’s trigger is a set of security types plus thresholds; when they’re met, the rule takes exactly one action.
- Incidents are the human side. Actions that raise an incident create a triage record — status, severity, assignee, and an append-only timeline — that your team works to resolution.
That flow is the reactive layer: it responds to signals after they’re reported. TillShield also has an inline layer — a request-time WAF that enforces a separate kind of rule (matched on the request itself, not on telemetry) at your own edge or origin. The two are distinct: reactive rules watch security events and take an account-level action; inline rules allow, block, challenge, or log a request as it arrives. See Inline enforcement.
How detection works
TillShield doesn’t collect anything itself. It consumes the security events your TillPulse SDKs emit and normalizes them into a small set of security types:
| Security type | What it means |
|---|---|
device_compromised | A rooted Android or jailbroken iOS device reported by the mobile SDK — a compromised trust boundary. |
cert_pinning_failure | A pinned connection whose certificate didn’t match. Often a man-in-the-middle in progress. |
overlay_capable_app_present | An app able to draw over your Android app: the classic tap-jacking / credential-capture vector. |
debugger_attached | A debugger attached to the mobile runtime, a common reverse-engineering and instrumentation signal. |
tamper_detected | Web and desktop runtimes report their own tamper indicators through the same channel. |
devtools_open | Browser developer tools opened on a web session, reported by the web SDK. |
csp_violation | A Content-Security-Policy violation the browser reported back through the SDK. |
threat_intel_match | A signal whose hashed indicator matched the shared cross-customer feed. See the privacy model. |
Severity and confidence
Every signal lands with two numbers you write rules against. Severity is how serious the detection is (low → critical). Confidence is how sure the SDK is that the detection is real, from 0 to 1. Keeping the two separate is deliberate: a low-confidence critical and a high-confidence low are very different situations, and you get to decide how each is handled.
Writing reactive rules
A reactive rule is a trigger and an action. The trigger selects which signals count and how many, over what window, before the rule fires. (For request-time rules matched at the edge, see Inline enforcement — a different rule shape.)
{
name: "integrity-lockdown",
when: {
types: ["device_compromised", "cert_pinning_failure"],
minSeverity: "high", // low | medium | high | critical
minConfidence: 0.8, // 0..1
threshold: { count: 3, within: "10m" },
},
then: { action: "revoke_sessions" },
testMode: false, // dry-run: log, don't act
cooldown: "30m",
}The trigger
types— one or more security types the rule watches.minSeverity— ignore anything below this severity.minConfidence— ignore anything the SDK isn’t sure enough about.threshold— acountof matching signalswithina time window. One-offs don’t have to fire the rule; a burst does.
The action
A rule takes exactly one of four actions when it fires:
raise_incident— open a triage record with the triggering events attached.quarantine_session— cut off the offending session, leaving the rest of the account intact.revoke_auth_sessions— end every auth session for the user; TillShield asks TillAuth to do it. Gated by default — see the note below.alert— notify the channel you already route TillPulse alerts to.
revoke_auth_sessions acts on the user named in the triggering signal. When that signal comes from your public SDK (a DSN that ships inside your app), the user id in it is self-asserted — a tampered client could name someone else. So by default TillShield withholds the automatic revoke and raises an incident for a human instead. If your SDK sets the TillPulse user id from a trusted server context and you want the revoke to run automatically, enable “Allow client-triggered revocation” under Shield → Settings (off by default). quarantine_session is unaffected — it only cuts the one session, not the account.Test-mode and cooldowns
Turn on testMode to run a rule as a dry-run: it evaluates the trigger and logs what it would have done, without doing it. Use it to tune thresholds against real traffic before a rule can revoke anyone’s session. A per-rule cooldown then caps how often the action can repeat, so a single persistent attacker can’t make the rule fire a hundred times a minute.
Inline enforcement (the WAF)
Reactive rules act after a signal is reported. The inline layer acts during the request: a WAF that runs at your own edge or origin and turns a detection into a decision before the response is served. It ships as two server-side SDKs and a drop-in Worker; you manage the rules and the threat-intel feed from the dashboard.
How it works on the hot path:
- Local evaluation. Your compiled ruleset — plus a sha256 threat-intel deny list — is fetched from TillDev and cached. Every request is then decided locally, in-process: no round-trip to TillDev on the hot path.
- Async reporting. Decisions are batched and reported back to TillDev out of band (never blocking the response), so they show up in your dashboard and feed the shared deny list.
- Fail-open by default. If the config can’t be reached before the first fetch, or an internal error occurs, the request is allowed through. Set the fail mode to
closedif you’d rather reject on uncertainty. - Optional incident roll-up. A per-rule flag rolls repeated blocks up into a TillShield incident, so the same triage workflow covers inline activity.
Edge keys
The inline SDKs authenticate with a per-project, server-side edge key — a secret prefixed tse_, minted in the dashboard under Shield → Inline WAF. It is distinct from your public ingest DSN: the DSN is a publishable client credential, the edge key is a server secret that must never ship to a browser or app bundle. Provide it as an environment variable and the SDK sends it as a bearer token when it fetches config.
tse_ edge key is a server secret. Keep it in an environment variable on your origin or Worker — never in client code, a browser bundle, or an app binary.The inline SDKs
The inline layer ships as two server-side SDKs and a zero-code Worker. Pick the one that matches where your traffic terminates — each fetches your ruleset, caches it, and enforces decisions in-process.
@tillstack/shield-node
For Node origins. One package, three adapters — Express / Connect middleware, a Fastify onRequest hook, and a raw http handler:
import { createShield } from '@tillstack/shield-node'
const shield = createShield({ edgeKey: process.env.SHIELD_EDGE_KEY })
app.use(shield.express()) // Express / Connect
// Fastify: fastify.addHook('onRequest', shield.fastifyHook)Blocked and challenged requests are answered by the SDK (with an x-tillshield header); everything else falls through to your app untouched.
@tillstack/shield-cloudflare
For Cloudflare Workers. It decides at the edge using Cloudflare’s own request signals (CF-Connecting-IP, request.cf.country). Wrap your fetch handler; pass a KV namespace for a rate limiter shared across isolates:
import { createShield, type ShieldCloudflare } from '@tillstack/shield-cloudflare'
let shield: ShieldCloudflare | null = null // env only exists per-request
export default {
fetch(req: Request, env: Env, ctx: ExecutionContext) {
shield ??= createShield({ edgeKey: env.SHIELD_EDGE_KEY, kv: env.SHIELD_KV })
return shield.wrap(() => new Response('ok'))(req, env, ctx)
},
}Full wiring — per-isolate caching, fail modes, KV rate limits, TillGate interstitials, key hygiene — in the Workers guide.
Drop-in edge worker (tillshield-edge)
Zero application code. tillshield-edge is a ready-made reverse-proxy Worker: set SHIELD_EDGE_KEY (as a secret) and SHIELD_ORIGIN (where clean traffic should go), deploy it, and route your hostname at it. Requests are screened at the edge and forwarded to your origin — nothing in your app changes.
wrangler secret put SHIELD_EDGE_KEY # tse_…
wrangler deploy --var SHIELD_ORIGIN:https://origin.example.com
# then point your hostname's route at the deployed workerConditions, modes, and rate limits
An inline rule has a priority (lower runs first; the first rule that matches decides the request), a set of AND-combined match conditions, a mode, and an optional rate limit. Values in an array are OR-combined; an empty match matches every request (pair it with a rate limit for a blanket limiter).
| Match condition | Matches when… |
|---|---|
ip_cidrs | the client IP falls in any listed CIDR range (IPv4 or IPv6). |
paths | the URL path matches any glob (* = any run of chars, ? = one char). |
methods | the HTTP method is one of these (case-insensitive). |
countries | the request’s country is one of these ISO-3166-1 alpha-2 codes (needs a country signal). |
user_agent_regex | the User-Agent header matches this regular expression. |
threat_intel | true and sha256(client IP) is in the shared cross-customer deny list. |
| Mode | Effect |
|---|---|
block | reject the request — 403 by default; override with response_status / response_body. |
challenge | return a soft-block status (e.g. 429) without serving your origin. |
log | observe only — record the decision, don’t touch the response. |
A rate_limit adds a token-bucket cap: requests per window_seconds, keyed by by: "ip" or by: "ip_path". When the bucket is exhausted the rule’s mode applies and a Retry-After is set.
{
name: "deny-known-hostile",
priority: 10, // lower runs first; first match wins
mode: "block", // block | challenge | log
match: {
threat_intel: true, // sha256(client IP) in the shared deny list
paths: ["/api/*", "/login"],
methods: ["POST"],
},
rate_limit: { requests: 20, window_seconds: 60, by: "ip" },
response_status: 403,
}Rules and the threat-intel deny list are managed from the TillShield section of your workspace; the SDKs only fetch and enforce them. Changes propagate on the next config refresh — no redeploy.
Incident triage
A raise_incident action creates a record your team works — not another alert to acknowledge and forget. Every incident has a status, a severity, an assignee, and a timeline.
Status
Incidents move through four states, in order:
open → investigating → contained → resolved- open — freshly raised, unowned.
- investigating — someone is on it.
- contained — the threat is stopped; cleanup remains.
- resolved — closed out, with the record intact.
The timeline
The timeline is append-only. Notes your team adds, every status change, and every action a rule took automatically are one ordered history — nothing is edited away after the fact. Assign an incident to a teammate and it shows up on their plate; the severity and the triggering events stay attached from open to resolved. Every one of these changes is also written to the shared TillDev audit log.
Threat intelligence & the privacy model
When a malicious package, certificate, or binary is seen attacking one TillDev customer, its indicator is shared across every org — so a threat that has hit someone else can surface for you before it reaches your users. The value is obvious; the trick is doing it without leaking anyone’s data.
The model is deliberately narrow:
- sha256 indicators only. What’s shared is a one-way hash of the offending artifact — a package name, certificate, binary, or hashed client IP — never the value itself. There is no reversible payload and no raw identifier in the feed.
- Hashes, never plaintext. We store only sha256 hashes, never plaintext, so no raw identifier lands in the shared table. The indicator’s job is simply to carry the type label (“this package is hostile”) — it flags a known-bad artifact across orgs; it is not a store of confidential data.
- Anonymous. No org identity travels with an indicator. You learn that a fingerprint is hostile; you never learn who it attacked first.
- Opt-out is real, and per-workspace. Contribution to the shared feed is on by default and can be turned off in Shield → Settings. Turning it off stops your indicators from being written to the global feed — and you still consume the shared feed either way.
- An implemented signal, not a footnote. When an incoming event’s hashed indicator is already known-hostile in the shared feed, TillShield emits a
threat_intel_matchsecurity event with its own severity and confidence — so the same reactive rules and actions apply. You can revoke sessions on a known-bad binary the same way you would on repeateddevice_compromisedreports.
Enabling SDK security capture
TillShield only sees what your SDK sends. Device-integrity, pinning, and overlay checks live in the TillPulse mobile SDKs and are enabled in your SDK configuration — turn on the checks you want and the events flow into TillShield automatically. Conceptually:
import { init } from '@tillstack/react-native'
init({
dsn: process.env.TILLPULSE_DSN,
// Security capture feeds TillShield. Enable the checks you need.
security: {
deviceIntegrity: true, // root / jailbreak
tlsPinning: true, // pinning-failure reporting
overlayDetection: true, // Android overlay abuse
},
})The exact option names and platform coverage live in the SDK reference — React Native and Flutter. Once security events are arriving, you write rules against them from the TillShield section of your workspace; no rule fires until you create one.
TillShield is part of TillDev — one workspace, one login, one audit log. See the workspace overview, or the docs for its siblings: TillPulse (the telemetry it reads) and TillAuth (the sessions it acts on). Prefer the pitch? See the product page.