---
title: "Device trust"
description: "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](/slopscale/ref/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.

```console
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](https://tailscale.com/docs/features/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.

```console
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:

```console
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:

```console
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:

```text
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.

```console
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.

```console
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](https://tailscale.com/docs/features/device-posture#supported-posture-providers).
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 to
  `https://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 to `https://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 to `https://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`, `inGracePeriod` or `unknown`.
  - `intune:azureADRegistered` (boolean): Whether the device is registered in Entra.
  - `intune:deviceRegistrationState` (string): `registered`, `notRegistered` or `revoked`.
  - `intune:isSupervised` (boolean): Whether the device is supervised.
  - `intune:isEncrypted` (boolean): Whether the disk is encrypted.
  - `intune:managedDeviceOwnerType` (string): `company`, `personal` or `unknown`.
- **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_ENCRYPTED` or `NOT_ENCRYPTED`.
  - `jamfPro:SIPEnabled` (string): `ENABLED` or `DISABLED`.
- **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 Block` or `Blocked`.

### Machine matching and synchronization

Machines are matched to provider records by the serial number the client
reports. [Identity collection](#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:

```text
falcon:ztaScore >= 80
intune:complianceState == 'compliant'
```

### Console and API

Posture integrations are managed in the console under _Integrations › Device posture_.

```console
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 (requires `devices:posture_attributes:read`).
- `POST /api/v1/posture-integrations`: create an integration (requires `devices:posture_attributes`).
- `GET /api/v1/posture-integration/{id}`: get an integration (requires `devices:posture_attributes:read`).
- `PUT /api/v1/posture-integration/{id}`: update an integration (requires `devices:posture_attributes`). An omitted secret keeps the stored value.
- `DELETE /api/v1/posture-integration/{id}`: delete an integration and clear its attributes (requires `devices:posture_attributes`).
- `POST /api/v1/posture-integration/{id}/sync`: sync an integration immediately (requires `devices:posture_attributes`).
- `POST /api/v1/posture-integrations/check`: verify credentials against the provider without saving (requires `devices:posture_attributes`).
- `GET /api/v1/posture-integrations/providers`: list supported providers and their attributes catalog (requires `devices: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](https://tailscale.com/docs/features/device-posture#posture-conditions):

```text
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.

```console
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`:

```json
{
  "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.
