Device trust
Suspend a machine without deleting it, and gate traffic on device posture, hardware attestation and MDM or EDR integrations through postures.
Approval decides whether a machine may join at all (see Device and user approval). Device trust is what an administrator can do with a machine after that: cut it off for a while without deleting it, and let the policy look at what the machine is before letting its traffic through.
Suspending a machine
A suspended machine stays registered and keeps its addresses and its key, but it gets no peers, no peer sees it, its packet filter and SSH policy are empty, and its client is told it is not authorized. The Tailscale client shows the same state as a machine waiting for approval, and a health message, “This device is suspended”, says why. Lifting the suspension gives everything back live; nobody has to sign in on the device.
Suspension is the reversible alternative to expiring the key or removing the machine: a lost laptop, a contractor on leave, a server under investigation. It is separate from approval, so a suspended machine is still approved and a suspended machine that is later unsuspended does not wait for approval again.
slopscale nodes list # the Approved column reads "suspended"
slopscale nodes suspend --identifier 7
slopscale nodes suspend --identifier 7 --revoke
Through the API, POST /api/v1/node/{id}/suspend, with
{"suspended": false} to lift it; the node object carries suspended and
suspendedAt. It needs the devices:core scope. The console has Suspend
in a machine’s menu and in the danger zone of its page, where a suspended
machine shows a red Suspended badge. Both edges are audited as
node.suspension.set and raise the nodeSuspended and nodeUnsuspended
webhook events.
The v2 API has no counterpart, because Tailscale’s API has none; a
suspended machine reads as authorized: true there.
Device posture
A posture is what the policy can check about a machine before it lets
traffic through, following
Tailscale’s device posture.
Each machine carries a set of attributes: most are derived from what its
client reports, the serial numbers are collected from the client on request,
and custom: attributes are set by an operator.
| Attribute | Source |
|---|---|
node:os |
Hostinfo, lowercased the way Tailscale writes it: linux, macos, windows, ios, android |
node:osVersion |
Hostinfo |
node:tsVersion |
Hostinfo, the client version without the build suffix |
node:tsReleaseTrack |
stable or unstable, from the client version |
node:tsAutoUpdate |
Whether the client has auto-update on |
node:hostname |
The name the client reports |
node:machine |
The CPU architecture |
node:distro, node:distroVersion |
Linux distribution and release |
node:deviceModel |
The hardware model on macOS, iOS and Android |
node:package |
How the client was installed |
node:tagged |
Whether the node is tagged |
node:hardwareAttested |
Whether the machine proved its hardware attestation key on its last map request |
node:tpm |
Whether the client found a TPM on the machine |
node:serialNumber |
The serial numbers the client collected, once identity collection is on |
custom:... |
Set through the API, the CLI or the console |
ip:address |
The address the machine’s control connection comes from, as seen by the server |
ip:country |
The ISO country code of that address; needs policy.geoip_database |
GET /api/v1/node/{id}/posture returns the whole map, the identity report
and the custom attributes; the v2 API has Tailscale’s
GET /api/v2/device/{id}/attributes. Both need devices:posture_attributes:read.
The console shows the map in a Device posture section of the machine’s
page.
Identity collection
The client only hands over serial numbers when asked, and only when its
user allowed it with tailscale set --posture-checking=true. The server
asks over the control connection (a “c2n” request carried by the map
stream, answered over the noise channel), so the machine has to be
connected. Collection is off by default; the postureIdentityOn setting,
slopscale settings set --posture-identity=true or Collect device
identity under the console’s Settings turns it on. While it is on, the
server asks each machine when it connects and again once a day, and
POST /api/v1/node/{id}/posture/collect, slopscale nodes posture collect
or the Refresh button asks now. A client with posture checking off
answers that it is disabled, which the posture shows so the operator knows
to ask the user.
Custom attributes
A custom attribute has a key of the form custom:name, a value that is a
string, a number or a boolean, an optional comment and an optional expiry.
An expired attribute stops counting at once and is deleted by a sweep; the
expiry is how a temporary marker such as an on-call rotation is made.
Setting one recomputes the policy for the machine.
slopscale nodes posture show --identifier 7
slopscale nodes posture set --identifier 7 custom:oncall=true --expiry 8h --comment "pager week"
slopscale nodes posture set --identifier 7 custom:tier=3
slopscale nodes posture delete --identifier 7 custom:oncall
Through the API, PUT /api/v1/node/{id}/attributes/{key} with
{"value": true, "expiry": "2026-09-08T09:00:00Z", "comment": "..."} and
DELETE on the same path; the v2 API has Tailscale’s
POST and DELETE /api/v2/device/{id}/attributes/{key}. Both need
devices:posture_attributes, which the owner, admins, network admins and IT
admins hold. The audit log records node.attribute.set,
node.attribute.delete and node.posture.collect.
Attributes set by the machine
A machine can set its own custom attributes over its control connection,
the way Tailscale’s experimental device attributes do: a management agent
on the machine reports what it found, and the policy checks it. The
client’s local API takes a patch, custom: keys with a string, number or
boolean value, or null to delete:
curl --unix-socket /var/run/tailscale/tailscaled.sock -X PATCH \
-H 'Content-Type: application/json' \
-d '{"custom:disk-encrypted": true, "custom:agent": "1.4"}' \
http://local-tailscaled.sock/localapi/v0/alpha-set-device-attrs
The server accepts this only while the deviceAttributesOn setting is on,
slopscale settings set --device-attributes=true or Machines set their
own attributes under the console’s Settings; it is off by default,
because whoever is root on a machine can then give it any attribute a
policy trusts. An attribute the machine sets is a custom attribute like
any other, with the comment Set by the machine, and the audit log
records it as node.attribute.set with the machine as the actor.
Hardware attestation
A posture attribute is only as good as the machine reporting it. Hardware attestation binds a machine’s identity to hardware it cannot copy: the client generates an ECDSA P-256 key inside the machine’s TPM, where the private half never becomes readable, and signs every map request with it. The server checks the signature and records what it proved, so the policy can insist that traffic comes from the physical machine that first presented the key rather than from a copied node key.
The client only does this when it is started with the flag:
tailscaled --hardware-attestation
It needs a build with TPM support (the stock Linux and Windows packages
have it) and a TPM 2.0 device. The Apple and Android clients never send
one, so their machines are never attested; node:tpm tells them apart
from a Linux machine that has no TPM at all.
What the server verifies
Every map request from such a client carries the public key, a timestamp
and an ECDSA signature over the SHA-256 of <unix seconds>|<node key>,
with the node key in its nodekey: form. The server verifies the
signature against the node key it holds for the machine, and rejects a
timestamp more than a day from its own clock: the client stamps its own
time, so the window bounds a replay rather than checks a clock.
The machine is attested for exactly as long as it keeps signing. A client that is restarted without the flag, or whose TPM stops answering, loses the attribute on its next map request, and every rule that requires it closes at once.
A signature under a key other than the stored one replaces it, because a
cleared TPM and a reinstall both produce a new key legitimately. The
change is recorded on the machine as keyChangedAt and audited, since it
also looks like a machine being impersonated.
Requiring it in a policy
The two attributes are ordinary posture attributes:
node:hardwareAttested == true
node:tpm == true
node:hardwareAttested is always present, true or false, so a rule can
require it with == true and nothing else. node:tpm is present as soon
as the client has reported anything about itself.
slopscale postures create --name "Attested hardware" \
--expr "node:hardwareAttested == true"
slopscale access-rules create --name "SSH from attested machines" \
--src 2 --dst 3 --protocol tcp --ports 22 --posture 1
In the policy file the same expression goes into a postures entry and
is named from srcPosture.
Console and API
A machine’s hardwareAttestation in the API holds attested, the key as
hwattestpub:<hex>, attestedAt (when attestation was gained, not the
last request) and keyChangedAt; it is absent for a machine that never
sent a key. tpm holds what the client reported about the device:
manufacturer, vendor, model, firmware version and specification revision.
Both are on the node object of GET /api/v1/node/{id}, and the machine’s
page shows them under Device posture.
DELETE /api/v1/node/{id}/hardware-attestation forgets the record, so
the next map request that carries a valid signature starts it again. The
client is not touched and keeps its key; this is what to do after a TPM
was cleared, so that the change of key is not recorded as one. It needs
the devices:core scope and is audited as node.attestation.reset, next
to the node.attestation.attested, node.attestation.lost and
node.attestation.key_changed entries the server writes on its own.
slopscale nodes attestation reset --identifier 7
Posture integrations
Posture integrations query external endpoint management (MDM) and endpoint detection and response (EDR) services for device state and turn the answers into prefixed posture attributes, following Tailscale’s device posture integrations. The attribute names match Tailscale’s naming conventions, so existing postures work without changes.
Providers and requirements
Slopscale supports six posture providers:
- CrowdStrike Falcon (
falcon:): requires an OAuth client ID (clientId) and secret (clientSecret). The API base URL (baseUrl) defaults tohttps://api.crowdstrike.com. - SentinelOne (
sentinelOne:): requires the console URL (baseUrl) and an API token (apiToken). - Microsoft Intune (
intune:): requires the Entra tenant ID (tenantId), client ID (clientId), and client secret (clientSecret). The API base URL defaults tohttps://graph.microsoft.com. - Jamf Pro (
jamfPro:): requires the Jamf Pro server URL (baseUrl), client ID (clientId), and client secret (clientSecret). - Kandji (
kandji:): requires the tenant URL (baseUrl) and an API token (apiToken). - Kolide (
kolide:): requires an API token (apiToken). The API base URL defaults tohttps://api.kolide.com.
Any provided base URL must be an http or https origin.
Attributes written by providers
Each provider writes attributes prefixed with its namespace:
- CrowdStrike Falcon (
falcon:):falcon:ztaScore(number): The Zero Trust Assessment score, 0 to 100.
- SentinelOne (
sentinelOne:):sentinelOne:operationalState(string): The agent’s operational state.sentinelOne:activeThreats(number): Unresolved threats on the device.sentinelOne:agentVersion(string): The installed agent version.sentinelOne:encryptedApplications(boolean): Whether disk encryption is on.sentinelOne:firewallEnabled(boolean): Whether the firewall is on.sentinelOne:infected(boolean): Whether the agent reports an infection.
- Microsoft Intune (
intune:):intune:complianceState(string):compliant,noncompliant,inGracePeriodorunknown.intune:azureADRegistered(boolean): Whether the device is registered in Entra.intune:deviceRegistrationState(string):registered,notRegisteredorrevoked.intune:isSupervised(boolean): Whether the device is supervised.intune:isEncrypted(boolean): Whether the disk is encrypted.intune:managedDeviceOwnerType(string):company,personalorunknown.
- Jamf Pro (
jamfPro:):jamfPro:remoteManaged(boolean): Whether MDM manages the computer.jamfPro:supervised(boolean): Whether the computer is supervised.jamfPro:firewallEnabled(boolean): Whether the firewall is on.jamfPro:fileVaultStatus(string):ALL_ENCRYPTED,SOME_ENCRYPTEDorNOT_ENCRYPTED.jamfPro:SIPEnabled(string):ENABLEDorDISABLED.
- Kandji (
kandji:):kandji:mdmEnabled(boolean): Whether MDM is enabled on the device.kandji:agentInstalled(boolean): Whether the Kandji agent is installed.
- Kolide (
kolide:):kolide:authState(string):Good,Notified,Will BlockorBlocked.
Machine matching and synchronization
Machines are matched to provider records by the serial number the client
reports. Identity collection must be on
(slopscale settings set --posture-identity=true or Collect device identity
in console Settings), and the client must report its serial number with posture
checking enabled (tailscale set --posture-checking=true).
Only one enabled integration per provider is allowed at a time, because each provider owns its attribute prefix.
- Scheduled sync: A worker goroutine syncs every enabled provider every 15 minutes.
- Sync on save: Creating or updating an enabled integration syncs it at once so the attributes exist before an operator writes rules against them.
- Sync now: An operator can trigger an immediate sync at any time via Sync now
in the console or
POST /api/v1/posture-integration/{id}/sync.
If a provider sync fails (for example, if the provider API is unreachable or
credentials are rejected), the server keeps the previous attributes on machines
so a transient failure does not disconnect devices from rules. The error is
recorded on the integration in lastError and shown in the console.
Example postures
Integration attributes are used directly in posture expressions:
falcon:ztaScore >= 80
intune:complianceState == 'compliant'
Console and API
Posture integrations are managed in the console under Integrations › Device posture.
slopscale posture-integrations providers
slopscale posture-integrations list
slopscale posture-integrations check --provider kolide --name "Kolide Fleet" --api-token "kld_..."
slopscale posture-integrations create --provider kolide --name "Kolide Fleet" --api-token "kld_..."
slopscale posture-integrations create --provider intune --name "Entra Intune" \
--tenant-id "00000000-0000-0000-0000-000000000000" \
--client-id "11111111-1111-1111-1111-111111111111" \
--client-secret "sec_..."
slopscale posture-integrations sync --id 1
slopscale posture-integrations delete --id 1
slopscale posture-integrations check verifies credentials with the
provider before saving. An integration syncs on save and every 15 minutes,
or immediately with sync.
Through the API:
GET /api/v1/posture-integrations: list integrations (requiresdevices:posture_attributes:read).POST /api/v1/posture-integrations: create an integration (requiresdevices:posture_attributes).GET /api/v1/posture-integration/{id}: get an integration (requiresdevices:posture_attributes:read).PUT /api/v1/posture-integration/{id}: update an integration (requiresdevices:posture_attributes). An omitted secret keeps the stored value.DELETE /api/v1/posture-integration/{id}: delete an integration and clear its attributes (requiresdevices:posture_attributes).POST /api/v1/posture-integration/{id}/sync: sync an integration immediately (requiresdevices:posture_attributes).POST /api/v1/posture-integrations/check: verify credentials against the provider without saving (requiresdevices:posture_attributes).GET /api/v1/posture-integrations/providers: list supported providers and their attributes catalog (requiresdevices:posture_attributes:read).
Postures
A posture names conditions a machine must meet. It has a list of
expressions, all of which must hold, and optionally a weekly schedule
outside of which it does not hold at all. A posture is attached to access
rules, where it narrows the rule’s sources to the machines that satisfy
it, and it can be written into the policy file the way Tailscale’s
postures and srcPosture work. Postures are evaluated on the server
from the attributes above, so a client cannot claim one.
Expressions
An expression is an attribute, an operator and a value, in Tailscale’s syntax:
node:os == 'macos'
node:tsVersion >= '1.80'
node:os IN ['macos', 'windows']
node:serialNumber NOT IN ['C02XYZ123']
custom:oncall == true
custom:blocked NOT SET
ip:address IN ['203.0.113.0/24', '198.51.100.9']
ip:country == 'VN'
The operators are ==, !=, <, <=, >, >=, IN, NOT IN,
IS SET and NOT SET. Values are strings in single or double quotes,
numbers, true and false, or a list in brackets. Version strings such
as node:tsVersion compare segment by segment, so '1.9' is smaller than
'1.10'. A string that is a CIDR matches an address inside it. An
attribute the machine does not have satisfies only NOT SET; a
list-valued attribute such as node:serialNumber satisfies ==, IN and
the ordered operators when any element does, and != and NOT IN when
none does.
ip:address is the address the machine’s control connection comes from,
so behind a reverse proxy it is the proxy’s address unless the proxy is
configured to preserve the client address. ip:country needs a MaxMind
GeoLite2 or GeoIP2 country database at policy.geoip_database; without
one the server refuses a posture that uses it. A machine’s source address
counts once it has connected; a posture that uses one of the ip:
attributes recomputes the policy for a machine when its address changes.
POST /api/v1/posture/check and slopscale postures check --expr ...
parse expressions without storing them. The console’s posture editor
colours each line, underlines a parse error where it is as it is typed,
warns about an attribute the server never reports or a value node:os
never takes, and completes attributes, operators and known values.
Schedules
A schedule is a set of weekdays, a start and an end as HH:MM, and an
IANA time zone, UTC when empty. An end before the start wraps past
midnight, so sat 22:00–06:00 runs into Sunday morning. The server
recomputes the policy when a schedule boundary passes, to the minute, so
the rules open and close on their own. A posture may have a schedule and
no expressions, which makes a plain time window.
Attaching postures to rules
A rule with postures admits a source machine only when it satisfies at least one of them; a rule without postures admits every machine in its source groups. The destinations are never narrowed. A posture a rule names cannot be deleted; remove it from the rule first.
slopscale postures create --name "Current client" \
--expr "node:tsVersion >= '1.80'" --expr "custom:blocked NOT SET"
slopscale postures create --name "Office hours" \
--days mon,tue,wed,thu,fri --start 09:00 --end 18:00 --timezone Asia/Ho_Chi_Minh
slopscale postures list
slopscale access-rules create --name "SSH from current clients" \
--src 2 --dst 3 --protocol tcp --ports 22 --posture 1
slopscale nodes posture show --identifier 7
Through the API, GET, POST /api/v1/posture, GET, PUT,
DELETE /api/v1/posture/{id} and the postureIds field of an access
rule, under policy_file (policy_file:read to list). GET /api/v1/node/{id}/postures lists the postures a machine satisfies right
now, and the console shows them under Device posture on the machine’s
page. The audit log records posture.create, posture.update and
posture.delete. In the console, postures live on the Postures page under
Access controls, next to the rules and groups, and a rule’s editor has a
Required postures picker.
Postures in the policy file
The policy file takes Tailscale’s postures, srcPosture and
defaultSrcPosture:
{
"postures": {
"posture:latestMac": ["node:os == 'macos'", "node:tsVersion >= '1.80'"],
"posture:office": ["ip:address IN ['203.0.113.0/24']"]
},
"defaultSrcPosture": ["posture:latestMac"],
"grants": [
{ "src": ["group:eng"], "dst": ["tag:prod"], "ip": ["22"], "srcPosture": ["posture:office"] },
{ "src": ["autogroup:member"], "dst": ["tag:web"], "ip": ["443"], "srcPosture": [] }
]
}
Posture names start with posture:. srcPosture on an ACL or a grant
narrows its sources to the machines that satisfy any of the named
postures; every expression inside one posture must hold.
defaultSrcPosture applies to every rule without srcPosture, and an
explicit empty srcPosture turns the default off for that rule. Postures
in the file have no schedule; use a database posture for that. The
posture:# prefix is reserved for the postures the access rules use.