Securing your app
This section documents the controls Axowl operates on your behalf, and the ones you configure yourself. Each item below maps to a mechanism that exists in the product today — where a control is opt-in or runs in log-only mode, it says so, because a security page that overstates coverage is worse than no page at all.
Transport & browser hardening
Section titled “Transport & browser hardening”Every API response carries a fixed set of hardening headers:
| Header | Value | What it stops |
|---|---|---|
X-Content-Type-Options | nosniff | MIME-sniffing a response into a script |
X-Frame-Options | DENY | Clickjacking via framing |
Referrer-Policy | strict-origin-when-cross-origin | Leaking paths/tokens in Referer |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Silent device access |
Strict-Transport-Security | max-age=31536000; includeSubDomains (HTTPS only) | Downgrade to HTTP |
Content-Security-Policy | frame-ancestors 'none' | Framing, in browsers that read CSP before X-Frame-Options |
X-XSS-Protection | 1; mode=block | Reflected script injection in older browsers |
TLS terminates at the edge; application containers speak HTTP only inside the private network.
The dashboard itself is served from the edge and carries the same family of headers: HSTS,
nosniff, Referrer-Policy, a Permissions-Policy that limits camera, microphone and geolocation
to the dashboard’s own origin and payment to that origin plus the payment provider’s script
origins, and X-Frame-Options: SAMEORIGIN with frame-ancestors 'self' so no other site can
embed it. A full Content Security Policy for the dashboard is deployed in report-only mode:
the browser reports what the policy would block without blocking it.
Session security
Section titled “Session security”Cookies. Session cookies are HttpOnly (JavaScript cannot read them), Secure over
HTTPS, and SameSite=Lax. A browser sign-in receives its token only in that cookie, never in a
response body, so a script injected into a page cannot read the session token or send it elsewhere.
CSRF. State-changing requests authenticated by cookie must echo a double-submit token
(X-CSRF-Token) that matches the axowl_csrf cookie; a mismatch is rejected with 403.
Requests that authenticate by Authorization: Bearer, API key, or webhook signature are not
cookie-driven and are verified by their own credential instead.
Session policy (per organization). Under Organization → Settings → Security Policy you
set maximum session duration, idle timeout, and the number of concurrent sessions per member.
The organization maximum cannot exceed the platform ceiling. The policy is applied when a member
session calls the organization’s workspace, once the organization has saved it: a session past
the maximum duration is ended with 401 SESSION_MAX_AGE, an idle one with 401 SESSION_IDLE,
and when a member exceeds the concurrent-session limit the oldest sessions are ended first.
Where the cookie-signing keys live. The keys that sign and encrypt Axowl’s own cookies (device-trust, deal and referral cookies) and a few server-side secrets (Roost organization settings) are kept in the main database, in the same backup and point-in-time-restore window as everything else — not in a cache that can be wiped by a restart. A server restart therefore never logs everyone out, and a database restore brings the keys back together with the data they protect.
Step-up re-authentication. Consequential actions — deleting an organization, transferring ownership, accepting a transfer, impersonation — require a fresh passkey assertion at the moment of the action, not merely a valid session. Axowl calls this stamping your seal. A stolen or forgotten-open session is not enough to perform them. The one exception is deleting an organization whose setup was never finished and that has no other members and no apps: its owner can delete it without a seal.
Sealed device. A workspace session is sealed only after a passkey ceremony on the device
in use; the server sets that flag itself, inside the session token it signs, and never takes it
from a value the browser reports about itself.
Sign-in paths that do not involve a passkey (magic link, social, SSO, invitation acceptance)
produce a sealed session only on a browser that already holds a device-trust cookie from an
earlier ceremony. The “no passkey, no workspace” rule is applied by the API, not only by the
dashboard, so a caller that skips the dashboard and speaks to the API directly gains nothing.
The API checks the flag on every workspace request from a member session: an unsealed session is
limited to the bootstrap surface (sign-in, refresh, passkey registration, reading your own
profile, organization creation and invitations) and receives 403 SEALED_DEVICE_REQUIRED elsewhere,
which the dashboard turns into the passkey prompt. Machine callers — API keys, server-to-server
tokens, agent connections, your end users — authenticate differently and are not subject to
this check. In production the check refuses; in the test environment it can run in log-only
mode, recording what it would have refused without refusing it (see Sealed-device enforcement
below).
Authentication
Section titled “Authentication”Passkeys (FIDO2/WebAuthn) are the primary factor; magic links, social providers, and enterprise SSO (SAML 2.0 and OIDC) are also supported. Passwords are not the default path.
Key binding. You choose where a member’s passkey private key may live: synced (roams across the member’s own devices via platform keychains) or device-bound (never exportable, strongest reclaim guarantee). Under device-bound, every synced passkey is refused on every platform. Under synced, a synced passkey is accepted on Android, iOS, iPadOS and macOS, where the platform’s own authenticator is a synced one, and refused on Windows and Linux desktops, which have a device-only authenticator; Google Password Manager passkeys are refused on every platform except Android.
Hosted sign-in: where a signed-in user is sent
Section titled “Hosted sign-in: where a signed-in user is sent”When your app uses the hosted sign-in page, the user signs in on Axowl’s origin and is then returned to your app. Two rules govern that return:
- The token never travels in a URL. The return address carries a one-time handshake code instead. The code is single-use and expires after 90 seconds; your app exchanges it for the end-user token.
- The return address must be one you registered. Before the user is sent back, the hosted
page checks the address: it must be
http/httpsand on the origin of one of your app’s Callback URLs. Any other address is not followed and receives no code — including an address placed in a crafted sign-in link by a third party. Ajavascript:or other non-web scheme is never followed.
This is why a new page that starts sign-in has to be added to your app’s Callback URLs first: an unregistered address is treated the same as an attacker’s.
The check on return runs in the hosted page itself. The server also validates the return address against the same list — before a magic-link email is sent, and before a social or SSO sign-in is started.
Device trust
Section titled “Device trust”Device policy sets the floor for which machines may reach your workspace:
- Open — any device is trusted on first successful passkey login (BYOD on).
- Hybrid — BYOD stays on, but the organization can pre-register company-owned devices and tell them apart; a device other than the member’s assigned one needs admin approval.
- Fortress — only pre-registered devices are admitted; unrecognized devices are blocked.
The mode also determines how a new device login is handled (allow / require approval / block), so the two settings cannot contradict each other.
Shared-device detection. By default a device is expected to belong to one member. If a different member signs in on the same device, admins are alerted and the user sees a banner. This detection is browser-based and therefore best-effort — it can be evaded by clearing cookies or switching browsers. Identity itself is always proven by the passkey, not by the device signal. Enforced, tamper-resistant device binding is a separate add-on.
Sealed-device enforcement (production). In production, a signed-in person can only call the
dashboard API from a device that has a registered passkey (a sealed device). A session that
arrived some other way — for example an email magic link on a machine with no passkey yet — gets
403 SEALED_DEVICE_REQUIRED until a passkey is registered on that device; machine callers (API
keys, agent tokens, app members) are not affected. The same production default applies to read
verification: a sealed record whose signature does not check out is not returned, instead of
being returned with a warning in the log. Test environments run both in log-only mode unless
switched on.
Access control
Section titled “Access control”Access is evaluated on two independent axes. Membership answers “is this person in this organization”; entitlement answers “may this person perform this action”. A role is a named bundle of scopes, and scopes are checked per action rather than inferred from a title. Billing is carved out to the Owner and is not granted implicitly by an admin role.
Tenant isolation
Section titled “Tenant isolation”Organization-scoped requests resolve the tenant before the handler runs: the caller must be an active member of the organization named in the URL, otherwise the request is rejected with 401 (not signed in) or 403 (not a member). Repository queries for organization-owned entities apply the resolved tenant as a mandatory filter, and return nothing or refuse to run when no tenant context is present — isolation fails closed rather than returning a broader result set.
Restricting who can reach your organization
Section titled “Restricting who can reach your organization”Two organization-level controls narrow the surface before authentication succeeds:
- Allowed email domains — members must present an email in your domain list to join or to register a device.
- Country access policy — logins from outside your allowed regions are allowed with an alert to admins, held for admin approval, or blocked outright, as you choose. The owner’s country is always allowed. Temporary grants cover business travel without weakening the standing policy.
Turning on either control — like the Hybrid and Fortress device modes, device-bound passkeys and
the shared-device allowance — requires the Advanced Device Trust add-on; without it the settings
API answers 402 ADDON_REQUIRED. Turning a control off never requires the add-on.
Desktop-app-only access. An owner can turn on desktop-app-only access for the organization’s
workspace (organization settings → Security). The check runs in log-only mode: access that
does not come from the Talon desktop app is recorded, not refused. A user-agent string or a custom
header proves nothing, because any client can send one, so the API does not look at either.
Instead the desktop app holds a non-exportable device key in the computer’s TPM, certifies a
short-lived session key (valid for at most 15 minutes) with it, and signs every API request (method, path and query,
time, a one-time nonce, and a hash of the session token) with that session key. The API checks
the chain, refuses a request older than two minutes or a nonce it has seen before, and accepts
the request only if that device key belongs to one of the organization’s allowed PCs. Allowed PCs
are company devices pre-registered under Devices with the Talon device ID that the desktop app
shows for that PC (field talonDeviceId on POST /api/org/{slug}/devices/register, 43
characters); blocking or deleting the device there removes it from the list. Requests from an
ordinary browser are answered with 403 DESKTOP_APP_REQUIRED when enforcement is on (enforced
in the response below). Only the owner can
change the setting, and the setting itself stays reachable from a browser with a passkey-sealed
session, so an owner cannot be locked out by turning it on. Machine callers (API keys,
server-to-server tokens, agent connections, end users) are not affected.
| Method | Path | |
|---|---|---|
GET | /api/org/{slug}/settings/desktop-only | {enabled, enforced, canEdit, currentClient, allowedPcCount} — currentClient is how this very request was judged (Valid, Missing, …) |
PUT | /api/org/{slug}/settings/desktop-only | body {enabled}; owner only, otherwise 403 OWNER_REQUIRED |
Bot and abuse resistance
Section titled “Bot and abuse resistance”Public sign-up style endpoints are gated by Cloudflare Turnstile. When the CAPTCHA secret is not configured, those endpoints refuse the request rather than accepting it unverified — a missing control is treated as a closed door, not an open one.
Secrets you hold
Section titled “Secrets you hold”API keys and plugin tokens are stored as SHA-256 hashes. Webhook signing secrets are kept server-side, because each delivery is signed with them, and are never returned by a read. For all three the plaintext value is shown exactly once, at creation (and again when a webhook secret is rotated), and cannot be retrieved afterwards. Each can be rotated or revoked independently, and rotation is recorded. Rotating the organization signing key invalidates outstanding signed permits, so plugins must refresh their cached public key.
Your third-party credentials (SAP, AWS, on-premise systems) are never sent to Axowl. A plugin token proves “this caller is your plugin” and grants no access to your cloud resources.
Tamper-evident audit trail
Section titled “Tamper-evident audit trail”Consequential records are hash-chained and sealed, so modification is detectable rather than merely discouraged — including modification made directly in the database. Verification is independent: a record can be re-hashed and checked against its stored seal without trusting the application that wrote it.
Supply chain and source hygiene
Section titled “Supply chain and source hygiene”Controls that run on the code before it ships:
- Dependency vulnerability gate. Every NuGet package, including transitive ones, is checked against the GitHub Advisory Database; a High or Critical finding fails the build. The same check covers the npm packages of the Cloudflare Workers that handle log ingestion and mail. Moderate findings are reported and picked up by the automated update flow below. The gate runs on every dependency-update pull request, on pull requests marked for a full verification run, and on a weekly schedule so that a newly published advisory against an unchanged dependency is still caught.
- Automated dependency updates. Minor and patch updates arrive as grouped pull requests each week — one group for the NuGet packages, one for the Workers’ npm packages and one for the documentation and blog sites.
- Secret scanning. The commits of every pull request are scanned for credential patterns (API keys, cloud access keys, JWTs, private keys) before merge; a hit fails the check. A scheduled scan covers the whole working tree. The allowlist is limited to binary and lock files, named test constants and documentation placeholders — never a real secret.
What this does not cover, stated so that it is not assumed: the two static documentation and blog sites are built with a generator whose own dependencies are reported but not gated, and static application security testing (SAST) of our source is not part of the pipeline.
Reporting a vulnerability
Section titled “Reporting a vulnerability”If you believe you have found a security issue, email [email protected] with enough detail to reproduce it. Please do not disclose publicly until we have responded. We do not pursue legal action against good-faith research that avoids privacy violations, service degradation, and access to data beyond what is needed to demonstrate the issue.