> Source: https://nexusatlas.dev/guides/admission-modes — Nexus Atlas developer documentation (Guides). Converted from the HTML page; the page is canonical.

# Admit nodes by pairing code, certificate or approval shipped

A record fleet refuses every key it holds no record for. Four admission modes give a new node a deliberate way in without editing the fleet list on the operator host: a single-use **pairing code**, a **certificate** signed by your fleet authority, a **pre-shared secret** as a second factor at the handshake, and a **queue** where an operator approves each knock. This guide picks a mode, configures both ends and shows what each one leaves in the journal. The mechanism is described under [Admission modes](https://nexusatlas.dev/docs/concepts/identity#admission).

> **What you need:** for pairing codes and certificates, a record fleet (`[auth] provider = "fleet"`) and a config author the admitting node can reach — the admitting node itself (its record carries `config_author`, or it holds the config-author key the fleet trusts) or another node it [forwards to](https://nexusatlas.dev/guides/admission-modes#forwarding). The pre-shared secret and the approval queue work under every provider. For the API, a token with write scope: `atlasd web token add backend --scope write`.

## Choose a mode

| Situation | Mode | Why |
|-----------|------|-----|
| A user adds a device from your application; hardware you did not build | Pairing code | One API call and a QR; least privilege unless the code grants more; one use |
| You build the hardware, and each unit must join any hub that trusts you with nothing to edit there | Fleet certificate | Signed once at the factory; no list entry; revoked through the list |
| A stolen node key must not be enough, and recorded handshakes must need more than the keys | Key plus secret | Two factors; a wrong secret gets no answer; the secret enters the session keys |
| Devices nobody can list in advance: a handset issued at an incident, a partner’s vehicle | Approval queue | The device knocks, a person decides, every decision is journalled |
| Your database already knows every device | The command hook or records written by your backend | Your system of record answers directly |

The modes combine. A pre-shared secret sits in front of any of them, and the approval queue catches the keys every other check refused.

## Pairing code

On the admitting node, mint an invitation. The pairing code goes to the new node as text or as a QR; it lives for `--ttl` and admits `--uses` nodes.

```
$ atlasd fleet invite --name drone07 --allowed-ip 10.1.0.17/32 --ttl 15m
invitation 7c41e09a2f5d4b8e9a3c11d07f62b5e4 for `drone07` — 1 use(s), expires in 15m
  classes Telemetry,Position,Bulk · relay no · address 10.1.0.17/32
  the code carries 1 anchor(s), 0 author(s), the address 10.1.0.17/32 and this node's address 10.1.0.1/24 — all under the token's MAC (202 characters)
atlas:pair?v=3&k=<this node's key>&n=drone07&a=<anchors>&i=10.1.0.17/32&h=10.1.0.1/24&t=<token>
```

### What the code carries

The pairing code is a `v=3` URI of about 200–260 characters (a version 10–12 QR). Its fields:

| Field | Carries | Under the token's MAC |
|-------|---------|-----------------------|
| k | The inviting node's X25519 key — the [[peer]] the new node handshakes with | yes |
| n, r, e | The suggested name, a region hint, an endpoint hint | no — hints nothing trusts |
| a | The fleet's anchors: the Ed25519 keys of the inviting node's config-authors.toml, at most four | yes |
| c | The config_author keys of the inviting node's list that are not anchors, at most four (absent when there are none) | yes |
| i | The address the record gets — the invitation's first allowed_ips; absent for a --pool code, which hands out the address at admission | yes |
| h | The inviting node's own tunnel address with the tunnel's prefix (10.1.0.1/24) | yes |
| t | The 40-byte token: id ‖ expiry ‖ BLAKE2s-MAC(invite secret, id ‖ expiry ‖ capabilities ‖ binding), the binding a BLAKE2s digest of k, a, c, i and h | — |

So a code whose anchor, author or address was altered does not verify, and the bare token is no longer enough: `[auth] invite_token` takes the whole code. The API answer spells the parts out as `code: {version, hub_key, anchors, authors, address, interface_address, hub_address, region}` with `uri_len`. A `v=1` code (key and name only) is still parsed; `v=2` is not.

- **Least privilege by default:** no relaying, Telemetry, Position and Bulk, `dashboard = "own"`. Raise it in the invitation with `--classes`, `--relay`, `--dashboard` and `--role`; the token’s MAC covers the flags, so the code cannot be edited into more.

- **Several nodes from one code:** `--pool 10.1.0.64/26 --uses 10` gives each node the next free /32 and numbers the names (`cam`, `cam-2` …).

- **Housekeeping:** `atlasd fleet invite --list` shows each invitation’s state and the nodes it admitted; `--withdraw <id>` voids one.

- If the node is not a config author, the command says so: a node it admits is kept there and queued for an author (see [Boundaries](https://nexusatlas.dev/guides/admission-modes#boundaries)).

From a platform backend, the same call over the [REST API](https://nexusatlas.dev/docs/api#admission):

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"name": "drone07", "allowed_ips": ["10.1.0.17/32"], "ttl_secs": 900, "uses": 1}' \
     http://hub-a:9800/api/admission/invites
{ "ok": true, "id": "7c41e09a…", "name": "drone07", "token": "…",
  "uri": "atlas:pair?v=3&k=…&n=drone07&a=…&i=10.1.0.17/32&h=10.1.0.1/24&t=…", "uri_len": 202,
  "code": { "version": 3, "hub_key": "…", "anchors": ["…"], "authors": [], "address": "10.1.0.17/32",
            "interface_address": "10.1.0.17/24", "hub_address": "10.1.0.1/24", "region": null },
  "expires_at": 1791805500, "uses": 1,
  "capabilities": { "relay": false, "classes": ["Telemetry", "Position", "Bulk"], … },
  "allowed_ips": ["10.1.0.17/32"], "can_sign": true }
```

On the new node, generate its identity with [atlasd keygen --identity](https://nexusatlas.dev/docs/cli#cmd-keygen-identity), list the inviting node as a peer (its key is the code’s `k=`) and put the whole code in `[auth] invite_token`. It needs no fleet list and no `config-authors.toml`; `[interface] address` may be left out (taken from the code’s `i` with the prefix of `h`), and so may the inviting node’s `[[peer]]` entry or its `allowed_ips` (the peer `k` with `h/32`, added when missing):

**the new node — first start**

```
[interface]
node_id     = "<record uuid>"
private_key = "…"
signing_key = "…"
# address: taken from the code (i, with the prefix of h) when left out

[[peer]]
public_key = "<the code's k= key>"
endpoints  = ["hub-a.example:51820"]
# allowed_ips: the code's h, as a /32, when left out

[auth]
provider     = "fleet"
invite_token = "atlas:pair?v=3&k=…&n=drone07&a=…&i=10.1.0.17/32&h=10.1.0.1/24&t=…"   # the whole code, never only t=
```

Its first handshake carries the code’s token and binding with its UUID, its signing key and a proof that it holds both. The inviting node spends the use, mints `drone07`, signs the next fleet list and floods it — and on every session with the node it sends `{"invite_ok": <binding>}` on the fleet-list channel, which the node takes only from the session with the code’s `k` and keeps across restarts (`<state dir>/<iface>.invite-confirmed`, journal `identity.invitation_confirmed`). The new node’s `identity.mode` reads `joining` until it adopts the first list signed by an anchor of its own `config-authors.toml`, by an anchor the code carried or a carried author the list itself names `config_author` (both counting once the hub has confirmed the code), or by a `config_author` record whose transport key is the inviting node’s; then `fleet`. So a node invited by a hub that cannot sign adopts the author’s list with no anchors file of its own, and the carried anchors are its anchors from then on; `identity.invitation` in the snapshot reads `{inviter, anchors, authors, confirmed}`. **A pool code** carries no `i`: the node starts with its interface up and unaddressed, and the hub’s confirmation names the address its record got with the tunnel prefix (`{"invite_ok": …, "address": "10.1.0.66/32", "prefix": 24}`), which the node configures on the spot — interface, adverts, relay-header origin, roster and the codes it mints — with no restart; snapshot `identity.tunnel_address` = `{address, source: config | invitation | record, pending, assigned_at}`, journal `identity.address_assigned`. A `[dns]` resolver on such a node needs an explicit `listen`. The same code presented again, or from a second key, gets no session: a code that is spent, past its expiry or withdrawn is journalled `admission.expired`, and a UUID or key the list holds or has revoked is refused without spending the use.

## Fleet certificate

On the authority’s host, sign each node’s record as a certificate. Describe the record on the command line, or certify one already in the authoring list with `--node drone-c --file <list>`:

```
$ atlasd fleet cert --name drone-c --node-id <uuid> \
    --transport-key <pub> --signing-key <pub> --allowed-ip 10.1.0.8/32 \
    --classes Telemetry,Video,Bulk --fleet tenant-1 --expires 365d \
    --key /etc/atlas/config-author.key --out drone-c.cert
certificate for `drone-c` (5e0b..c4a1) in fleet `tenant-1` signed by 3c24f045 → drone-c.cert (812 bytes, expires 1823341500)
on the node: [auth] cert_file = "drone-c.cert"
```

The signing key must be an anchor in each admitting node’s `config-authors.toml`, or the signing key of a `config_author` record in its list; the command warns when it is not an anchor on the host it runs on. On the node, the certificate replaces the fleet list:

```
[auth]
provider  = "fleet"
cert_file = "/etc/atlas/drone-c.cert"
```

Any node that trusts the authority admits it with the certificate’s flags (`admission.cert_accepted`) and offers the certificate on over its sessions, so every member learns the record and verifies the node’s signed adverts. To take it out, revoke the certificate and push: `atlasd fleet revoke --cert drone-c.cert --push /etc/atlas/config.toml`. Every node evicts it in the same intake, and the certificate is refused from then on, even from a factory-fresh start.

## Key plus secret

Give every node of the fleet the same 32-byte secret:

```
$ head -c 32 /dev/urandom | base64

[auth]
psk = "…"        # the same on both ends of every pair
```

For secrets per group of nodes, put named keys in `[auth] psk_file` (one `id = "<base64>"` line each, mode 0600) and name one in a record’s `psk_id` (or with `fleet cert --psk-id`). A pair whose records name a `psk_id` uses the key of the lower node id; both ends compute the same.

Every handshake initiation then carries a tag over its bytes under the secret, and the handshake runs Noise IKpsk2, so the secret enters the session keys too. A missing or wrong tag is dropped with no answer at all — not even a cookie — and counted in `admission.counters.psk_dropped`. From the sender’s side it looks exactly like a port nobody listens on. With a fleet-wide secret the tag is checked before any Diffie-Hellman.

## The approval queue

```
[auth]
pending     = true
pending_max = 200     # default: knocks beyond this are dropped
block_secs  = 3600    # default: how long a denied key stays blocked
```

A key that fails every other check — no record, a refused invitation, a denied hook — is parked with its source, the name it calls itself, the UUID it proved, first and last seen and a count. A knock is listed within half a second:

```
$ atlasd fleet pending list
5d2e81a0  from 198.51.100.23:51820   "phone-07"         4× first 9s ago last 1s ago

$ atlasd fleet pending approve 5d2e81a0 --allowed-ip 10.1.0.70/32 --classes Position,Telemetry
approved as `phone-07` (9b1f..07c2), 10.1.0.70/32 — in fleet list seq 3

$ atlasd fleet pending deny 5d2e81a0 --block 1h --reason "not ours"
denied 5d2e81a0 — blocked in 1h
```

Approve mints the record with the flags you give (least privilege for the rest), signed into the next list by a config author, and the knocking node’s next retry gets a session — about two seconds later, with no restart. Deny drops the key’s handshakes unanswered until the block ends; `atlasd fleet pending unblock <id>` lifts it early. The dashboard’s Peers tab opens with an **Admission · Pending** card that does the same: Approve with the name pre-filled from what the node calls itself, an address or pool, classes and relay; Deny; Unblock; and **Invite a node**, which shows a pairing code as text and QR once. From a backend:

```
curl -s -H "Authorization: Bearer $TOKEN_R" http://hub-a:9800/api/admission/pending
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" -H "Content-Type: application/json" \
     -d '{"id": "5d2e81a0", "allowed_ips": ["10.1.0.70/32"], "classes": ["Position", "Telemetry"]}' \
     http://hub-a:9800/api/admission/approve
{ "ok": true, "id": "5d2e81a0", "name": "phone-07", "node_id": "9b1f…", "signed": true, "queued": false, … }
```

## When the admitting node cannot sign

A hub that admits a node by pairing code or approval but cannot sign the fleet list keeps a local record that admits the node there, and hands it to a config author on its own: over the session when the author is a neighbour, end to end on the control channel when it is several hops away. Nothing to configure on the hub beyond end-to-end sealing for the far case; the author needs a record with `config_author` and its key.

```
# on the hub: lets it reach an author that is not a neighbour
[crypto]
e2e = true

# on the author (default true): sign forwarded records at once
[auth]
accept_forwarded = true
```

About a second after the admission the author has signed the record into the next list and flooded it, and the hub drops its local copy. On the hub, `atlasd fleet pending list` shows each record on its way:

```
$ atlasd fleet pending list          # on the hub
no keys waiting for approval
queued `drone01` (invite:7c41e09a2f5d4b8e9a3c11d07f62b5e4) — accepted (author `gs`)
```

The author applies its own policy, not the hub's word: the hub must hold a record, the record must have the shape a node mints, it is never a config author, and it carries no right the hub lacks itself. A drone invited with Video by a hub without Video is refused `exceeds_forwarder` and keeps working with that hub only. Other refusals are `collides` (an id, name or key already held) and `revoked`.

To decide each forwarded record by hand, set `accept_forwarded = false` on the author:

```
$ atlasd fleet pending list          # on the author
no keys waiting for approval
forwarded 3f9c0e12  `drone02` 10.1.0.22/32 from `hub1` (invite:b81d44c09e2a4f37a0c5e19d62f7a301) 4s ago  — awaiting approval
$ atlasd fleet pending approve 3f9c0e12
$ atlasd fleet pending deny 3f9c0e12
```

Approve signs the record exactly as minted; deny refuses it, and the hub's next forward is answered `refused`, `denied`. The dashboard's Pending card lists forwarded records with Approve and Refuse. With no author reachable the hub retries, 5 s doubling to 5 minutes, and counts `forward_unreachable`.

## Seeing it

| Event | Journal | Also in |
|-------|---------|---------|
| Invitation minted, withdrawn | admission.invite_created, admission.invite_revoked | GET /api/admission/invites (never the token) |
| Node admitted by a code | admission.invited, with the operator who minted it | the invitation’s admitted list |
| Spent, expired or withdrawn code presented | admission.expired | admission.counters.token_refused |
| Certificate accepted, refused | admission.cert_accepted, admission.cert_refused | admission.certified |
| Wrong or missing secret | — | admission.counters.psk_dropped |
| Record forwarded to an author, answered | hub: admission.forwarded (author, path), forward_waiting, forward_accepted, forward_held, forward_refused (reason), forward_listed; author: admission.intake_accepted, intake_held, intake_refused, intake_rejected | admission.queued, each local record's forward state, the matching counters |
| Knock, approval, denial, unblock | admission.knock, admission.approved, admission.denied, admission.unblocked, with the operator (web:<user>, token:<name>, cli:<user>) | admission.pending, admission.blocked, counters.knocks, counters.blocked_dropped |

`GET /api/admission` returns the whole snapshot block: `admission.{pending_enabled, psk, can_sign, pending, blocked, invites, local, certified, queued, accept_forwarded, held, counters}`.

## Boundaries

- Pairing codes and certificates admit into a record fleet; a node without a fleet list cannot mint an invitation (`409 not_a_record_fleet`).

- A node that cannot sign the fleet list forwards the records it mints to an author; an author that is not a neighbour takes the forward only sealed end to end, so the hub needs `[crypto] e2e` for it. A refused record stays on the hub, still admitting its node there; remove it, or bring it into the authoring file with `atlasd fleet pull -c <config> --with-queued`, sign and push.

- The node a hub that cannot sign admitted adopts the author's list as its first list only when an anchor of its `config-authors.toml` (or its inviter) signed it. Until then it stays `joining`: every other node knows it from the list, and it reaches its configured peers directly.

- A joining node learns the mesh secret only from a rotation sealed to its record.

- A certificate is at most 940 bytes and carries no geofence. A revoked certificate whose record named a `psk_id` is refused at the secret check, so it shows in `psk_dropped` rather than as `admission.cert_refused`.

- A handshake initiation is 162 bytes bare, 319 with a pairing code and about 850 with a certificate and a code; the daemon checks at start that it stays within 1,200.
