---
title: "Admin console"
description: "Sign in to the built-in admin console through your identity provider, invite users, end console sessions, find each page, and build it from source."
---

Slopscale ships a web console at `/console/` on the server's own address. It
lists machines, users and keys, edits the policy, approves devices and users,
shares nodes and marks the global exit node from a browser, the same things
the CLI and the API do.

The console is a static, client-rendered application embedded in the
`slopscale` binary. It keeps no state on the server and needs no extra process:
open `https://<your server>/console/` and it loads. The server's front page,
`https://<your server>/`, sends a browser there too, and the console's old
address, `/admin/`, still works: a link made before 0.31 is redirected to the
same page under `/console/`.

## Signing in

The console signs in only through the [identity provider](/slopscale/ref/oidc): the
sign-in page has one button, *Continue with Google* (or the provider's name),
and nothing else. The browser is sent through the provider and comes back
signed in as the matching slopscale user, holding a session cookie that lasts
seven days; *Sign out* in the account menu ends it. The provider's redirect
URI is the same `/oidc/callback` as for device logins, so nothing more has to
be registered.

The client ID and secret go either in the configuration file or in the
environment, whichever suits the deployment:

```yaml
oidc:
  issuer: https://accounts.google.com
  client_id: 1234567890-abc.apps.googleusercontent.com
  client_secret: GOCSPX-...
```

```console
$ export SLOPSCALE_OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
$ export SLOPSCALE_OIDC_CLIENT_SECRET=GOCSPX-...
```

Every key of the configuration can be set this way: `SLOPSCALE_` followed by
the key path with dots replaced by underscores.

A user who signs in for the first time is created the same way as on a device
login, including [user approval](/slopscale/ref/approval) when it is on: until an
administrator approves them, the sign-in page says so and opens no session.
The first user of an empty server becomes its owner. Anyone else who should
administer the server from day one is named in `oidc.admin_users`: an
address on that list is made an admin the moment it signs in, so nobody has
to hand out roles over the CLI first.

```yaml
oidc:
  admin_users:
    - alice@example.com
```

```console
$ export SLOPSCALE_OIDC_ADMIN_USERS="alice@example.com bob@example.com"
```

The list is checked on every sign-in and only ever promotes a member; the
owner and users who already hold a role keep it, and removing an address
does not demote anyone (use `slopscale users set-role` for that). The
promotion is written to the audit log as a system `user.role.set`.

Without an identity provider the console cannot sign anyone in and says so;
the CLI and the API keep working with API keys.

What the console can show and change is decided by the signed-in user's
current [role](/slopscale/ref/roles), read on every request:

- A role change takes effect at once, and deleting the user ends their
  sessions. So does _Sign out everywhere_.
- An auditor sees everything and can change nothing, an `it-admin` cannot
  approve routes, and so on. Pages the user cannot read are hidden; actions
  they cannot take are disabled.
- A member sees the overview, their own machines and the ones shared with
  them, their own API keys, their access requests and their console
  sessions. They can rename, expire, remove and share a machine of their
  own, and nothing else; see [what a member gets](/slopscale/ref/roles#what-a-member-gets).

Everything the console changes is written to the [audit log](/slopscale/ref/audit) with
the signed-in user as the actor.

## Sessions

A sign-in opens a session that lasts seven days. There is no sliding renewal,
so a stolen cookie is bounded the same way as a fresh one, and _Sign out_ in
the account menu ends the session it was made from.

Every session is listed under _Sign-ins_ with the user it belongs to,
when it was opened, when it last made a request, and the address and browser it
came from. An administrator sees every session and can end any of them; a
member sees and ends only their own. Ending a session takes effect on that
browser's next request: it is asked to sign in again.

```console
$ curl -H "Authorization: Bearer $KEY" https://<your server>/api/v1/auth/sessions
$ curl -X DELETE -H "Authorization: Bearer $KEY" \
    https://<your server>/api/v1/auth/sessions/7
```

`slopscale sessions list` prints the same table, `--user <id>` narrows it to one
user, and `slopscale sessions end <id>` ends a single session:

```console
$ slopscale sessions list --user 3
$ slopscale sessions end 7
```

`DELETE /api/v1/user/{id}/sessions`, _Sign out everywhere_ on the user's page,
or `slopscale users sign-out --identifier <id>` signs one user out of every
browser, which is what to reach for when a laptop goes missing; it changes
nothing else about the account. All three are recorded in the
[audit log](/slopscale/ref/audit) as `console.logout`, `session.end` and
`user.sessions.end`.

The address a session records is the one the request came from after
`trusted_proxies` was applied, so a deployment behind a reverse proxy shows the
browser's address rather than the proxy's. A session opened before this version
shows neither an address nor a browser.

## Inviting users

An administrator can invite someone by email instead of waiting for them to
find the sign-in page. _Users → Invite_ asks for the address, the
[role](/slopscale/ref/roles) and any [groups](/slopscale/ref/access-control) the person should join,
and hands back a link; `slopscale invites create` does the same from the CLI:

```console
$ slopscale invites create --email ada@example.com --role admin --group 3 --expiry 72h
$ curl -X POST -H "Authorization: Bearer $KEY" \
    -d '{"email":"ada@example.com","role":"admin","groupIds":["3"],"expiry":"72h"}' \
    https://<your server>/api/v1/invite
```

`--group` is repeatable and `--expiry` defaults to `168h`, a week, with `720h`
the longest the server accepts. `slopscale invites list` shows which
invitations are still pending, `slopscale invites resend <id>` mints a fresh
link for one, and `slopscale invites delete <id>` withdraws it.

The link is `https://<your server>/console/login?invite=<token>` and is shown
once: the server keeps only a hash of the token, as it does for a session
cookie or a pre-auth key. When [`notifications.smtp`](/slopscale/ref/webhooks) is
configured the link is also mailed to the address, and the response says
whether that worked (`emailSent`, with `emailError` when it did not). A mail
that cannot be sent does not fail the invitation. The link works either way
and can be passed on by hand.

An invitation is consumed by the first login it fits, in either of two ways:

- the person opens the link, which carries the token through the identity
  provider and back, or
- the person signs in by themselves and the identity provider vouches for an
  email that matches a pending invitation.

Either way the user is created **approved**, even while
[user approval](/slopscale/ref/approval) is on, because an administrator already vouched
for the address, with the invited role and groups. An account that already
exists keeps the role and groups it has and the invitation stays pending; an
invitation cannot promote someone who is already signed up.

Invitations expire: seven days by default, thirty at most. An expired,
revoked or already-used link says so on the sign-in page rather than signing
the person in without the role they were promised.

```console
$ curl -H "Authorization: Bearer $KEY" https://<your server>/api/v1/invite
$ curl -X POST -H "Authorization: Bearer $KEY" -d '{}' \
    https://<your server>/api/v1/invite/4/resend
$ curl -X DELETE -H "Authorization: Bearer $KEY" https://<your server>/api/v1/invite/4
```

Re-sending mints a new token and a new expiry, so the link in the previous
mail stops working. Revoking deletes the invitation.

An invitation cannot hand out ownership: the tailnet has exactly one owner and
that role moves only by [transfer](/slopscale/ref/roles). The invited address must be free
of both an existing user and another pending invitation, otherwise the request
is refused.

Creating, re-sending and revoking are recorded in the
[audit log](/slopscale/ref/audit) as `user.invite.create`, `user.invite.resend` and
`user.invite.delete`; accepting is recorded as `user.invite.accept` against
the user it created.

## Getting around

The sidebar starts with the overview and is then grouped by what you are
doing. *Tailnet* holds machines and users. *Access* holds the access controls,
the keys, the page to ask for temporary access and the console sign-ins; a
member sees their own machines, their own API keys and their own sign-ins.
*Connectivity* holds networks, routes, services, apps, DNS and relays. *Logs*
holds the audit log and the [SSH session recordings](/slopscale/ref/ssh-recording).
*Settings* holds the tailnet settings, the server page and the integrations,
and only a role that may read them sees it. An item with several parts, such
as _Access controls_, _DNS_, _Relays_, _Keys_ or _Integrations_, opens into a
page per part, each with its own address.
A count next to _Machines_, _Users_, _Routes_ and _Requests_ says how many
are waiting for approval. The arrow in the sidebar footer
collapses it to an icon rail. *Quick search*, or <kbd>⌘</kbd> <kbd>K</kbd> / <kbd>Ctrl</kbd> <kbd>K</kbd>,
jumps to any page, machine or user by name. The top bar shows breadcrumbs
for where you are, the light/dark switch and the account menu with *Sign
out*.

## Pages

- **Overview**: counts, machines and users waiting for approval (approve them
  in place), the machines seen most recently, and a getting-started
  checklist while the tailnet is empty.
- **Machines**: every node with its owner or tags, addresses, status and
  routes. Search by name, address, user or tag and filter by status, user or
  tag; the filters live in the URL, so a filtered list can be shared. Tick
  rows to approve, expire or delete them in one go. The list refreshes on
  its own every fifteen seconds while the tab is open.
  Each machine has a detail page with its keys, routes and services (approve
  with a switch), sharing and the global exit node switch with its failover
  priority, plus rename, tag,
  expire and remove. For an online machine running Tailscale SSH, an _SSH_
  button opens a terminal in the browser; see
  [SSH from the console](#ssh-from-the-console). For client updates, health
  diagnostics, live preferences and attestation, see
  [Managing a machine's client](#managing-a-machines-client).
  A warning the client reports about itself, such as a subnet router whose
  kernel drops forwarded packets, shows at the top of the page until the client
  stops reporting it or goes offline.
  _Add machine_ mints a pre-auth key and hands over the join command for
  Linux, macOS, Windows and Docker, next to a QR code carrying the same line
  so a phone or a machine without a shared clipboard can pick it up; the
  iOS and Android tab gives the server address and where the app takes it.
- **Users**: create, invite, rename, approve, change the [role](/slopscale/ref/roles) and
  delete users. Each user has _Sign out everywhere_, which ends every console
  session of theirs. _Invite_ mints a link for an address, with the role and
  groups the person gets on their first login.
- **Keys**: a page each for pre-auth keys (create with reusable, ephemeral,
  pre-authorized and tags; expire; delete), API keys and OAuth clients for
  the v2 API (create with scopes and tags; revoke; _Rotate secret_ mints a
  new secret for an API key that keeps its id and scopes). New keys and
  client secrets are shown once, with a copy button. The OAuth clients page
  also holds [federated identities](/slopscale/ref/api#workload-identity-federation):
  _New federated identity_ registers the issuer, audience and subject a
  workload's own OpenID token carries, with optional claim rules, and the
  scopes and tags it gets; the page then shows the token exchange call and
  a GitHub Actions snippet to copy, since no secret exists. A _Kind_ column
  and filter tell the two apart, and the row menu edits a client's scopes
  and tags or an identity's trust conditions in place.
- **Access controls**: a page each for the [rules, groups](/slopscale/ref/access-control)
  and [postures](/slopscale/ref/device-trust), the access [graph](/slopscale/ref/access-control#access-graph)
  showing who can reach what, the queue of
  [access requests](/slopscale/ref/temporary-access), and the [policy](/slopscale/ref/policy) file
  in an editor that colours HuJSON, underlines what the server would refuse
  as you type (an unknown key, a group no section defines, an autogroup on
  the wrong side, a bad port), completes section names, rule keys and the
  names the file defines, explains a key or an autogroup on hover, and sends
  the draft to the server for the checks only it can do once typing pauses.
  The band above the file counts what is wrong. *Check* validates the draft
  against the server without saving; *Save* applies it. Leaving the page
  with unsaved changes asks first. The posture editor colours each
  expression the same way and underlines a parse error on the line it is
  on.
- **Networks**: [networks](/slopscale/ref/networks) that hand subnets and exit nodes to
  groups.
- **Routes**: every route any machine advertises, with approval for the ones
  no network owns.
- **Services**: [services](/slopscale/ref/services) with their addresses, DNS name and
  hosts, and a switch to approve each host.
- **Apps**: [apps](/slopscale/ref/apps) reached through app connectors, with their domains,
  connector selectors, static routes, and the connector nodes with their learned
  and pending routes.
- **DNS**: a page each for nameservers with MagicDNS and search domains, split
  DNS for every machine or for some groups, and extra records, all changed at
  runtime; see [DNS](/slopscale/ref/dns).
- **Relays**: a page each for the DERP map machines receive, the map sources
  and refetch schedule, the relays you run yourself, the embedded relay, and
  relay [latency](/slopscale/ref/derp#latency) reporting measured round trips and home
  regions across the tailnet.
- **Integrations**: a page each for
  [device posture integrations](/slopscale/ref/device-trust#posture-integrations)
  (CrowdStrike Falcon, SentinelOne, Microsoft Intune, Jamf Pro, Kandji, Kolide),
  webhooks (endpoints that receive signed event notifications, with their
  subscriptions and last delivery; see [Webhooks](/slopscale/ref/webhooks)), and log
  streams, which ship the audit log to a SIEM in batches.
- **Audit log**: who changed what, newest first, with filters by action, user
  and time, and _Export_ as CSV or JSON with the same filters; see
  [Audit log](/slopscale/ref/audit).
- **Sign-ins**: the console [sessions](#sessions) with _End_ and _Sign out
  everywhere_ next to the signed-in credential's role and scopes.
- **Settings**: a _Tailnet_ page for the tailnet switches
  ([device and user approval](/slopscale/ref/approval), device trust, the key expiry cap,
  [SSH recording](/slopscale/ref/ssh-recording), and the state of [tailnet lock](/slopscale/ref/tailnet-lock) with
  _Switch off_), and a _Server_ page for the server's build, addresses, DERP
  regions and config file values with a _Maintenance_ section holding the IP
  address backfill (`slopscale nodes backfillips`).

## SSH from the console

The machine page's _SSH_ button opens a terminal in the browser for an online
machine that runs Tailscale SSH (`tailscale set --ssh`, which the machine
record reports as `sshServer`).

The console runs Tailscale's in-browser client (WebAssembly), which joins the
tailnet as an ephemeral machine owned by the signed-in user with a one-time key
valid five minutes (`POST /api/v1/ssh-session`). Because the client connects as
a machine of that user, the SSH [policy](/slopscale/ref/policy) decides whether the
session is allowed exactly as for any other machine of theirs:
`autogroup:member` and user rules match the signed-in user, while a tagged
machine needs a rule that admits the user. The ephemeral machine disappears
when the tab closes. A user whose role does not read devices can only open a
session to a machine of their own or one a machine of theirs already sees
under the policy; any other machine is reported as not found.

The SSH page offers the logins the machine suggests. They are a hint, not an
authorisation, the SSH policy still decides, and when the machine names exactly
one, it fills an empty field.

Connecting from the browser needs the target machine's [DERP relays](/slopscale/ref/derp)
to be reachable over websockets (`wss:`). The
[embedded relay](/slopscale/ref/derp#embedded-derp) supports it; a relay that does not
answers with a connection that never comes up.

A console built without `make wasm` shows a notice on the terminal page.

## Managing a machine's client

The console can query a connected machine and change what its client does;
see [device management](/slopscale/ref/device-management). Every one of these questions
goes to the machine over its control connection. A machine that is offline, or
one with remote configuration off, answers 409; a client that refused answers
502 with its message; a machine that did not answer in time gives 504. The
console shows these in place rather than as a notification.

A machine's Overview lists [hardware attestation](/slopscale/ref/device-trust) as
Attested, Lost or Not attested. Lost means the machine once proved itself with
its hardware attestation key and its last map request did not; hovering the
state shows when it was attested and when the key last changed. A machine whose
client reported a TPM gets a TPM row with the manufacturer, vendor and firmware
version. The Remote configuration row says whether the machine ran
`tailscale set --remote-config`, which hands its local API to the tailnet admin.
Actions → Reset hardware attestation clears what the key proved without
touching the client. The next map request the machine signs starts the record
again; until then `node:hardwareAttested` is false and any posture checking it
fails. The machines list filters by attestation (Attested / Not attested). The
filter appears once some machine reports an attestation key and rides in the URL
as `?attested=`.

The Client row offers Update now while the machine is connected, a newer stable
client exists and the caller has the `devices:core` scope. The control plane
cannot push an update: the client decides and refuses unless its owner allowed
it with `tailscale set --auto-update=true`, and the console reports what it
answered. The bulk bar's Update clients asks the ticked machines that are
connected and behind, in one request, and reports "Started on N, M refused" with
a dialog listing what each client said.

The Client health section shows the warnings the client would show its own
user, with the client's severity, how long it has been broken and whether
traffic is affected. Refresh asks again. An offline machine is not asked. The
Diagnostics block under Client health downloads the client's own dumps
(preferences, network map, metrics, goroutines, socket stats and the tailnet
lock log) as the client wrote them. It needs `devices:core`.

The Preferences section reads the machine's own settings live: advertised
routes, whether it accepts routes and DNS, the exit node it uses, Tailscale
SSH, shields, posture reporting and auto-update. Editing them needs remote
configuration on; without it the section shows the command that turns it on and
has no Edit button. Only the fields changed are sent, so a setting the machine's
owner changed meanwhile is left alone.

Connectivity gains a TLS certificate row for a machine that runs Funnel or
announces a service: Valid, Missing, Expired, or what the client said went
wrong. An [app connector's](/slopscale/ref/apps) page gains Learned routes: the addresses
the machine has resolved for the domains it answers for, refreshable.

## Building from source

Release binaries and container images include the console. When building from
source, build it first so the binary embeds it:

```console
$ make web
$ make build
```

`make web` needs [bun](https://bun.sh) (the Nix development shell provides it).
`make web` now also runs `make wasm`, which compiles
`tailscale.com/cmd/tsconnect/wasm` with the Go toolchain (about 30 MB raw,
7 MB gzipped, embedded gzipped), and the client is served under
`/console/tsconnect/`. Running `make wasm` alone builds only the WebAssembly
client. A binary built without `make web` still serves `/console/`, with a page
saying the console is missing, and the API works as usual. A console built
without `make wasm` shows a notice on the terminal page.

The console lives in `web/`: React with the TanStack router, query and table
libraries and Cloudflare's [Kumo](https://kumo-ui.com) design system (Base UI
components and Tailwind CSS), checked
by TypeScript, oxlint and oxfmt. Its API types are generated from the server's
OpenAPI document by `make web-generate` and committed. `bun run dev` in `web/`
starts a development server that proxies `/api` and `/oidc` to a slopscale on
`http://127.0.0.1:8080` (set `SLOPSCALE_URL` to point elsewhere).

### Signing in without Google

The console only signs in through an identity provider, so development and
tests need one that asks no questions. `go run ./cmd/dev` starts a slopscale
with a mock OpenID Connect provider running inside the same process: every
sign-in comes back as `jane.doe@example.com`, who is listed in that server's
`oidc.admin_users` and therefore opens the console as an admin.

```console
$ go run ./cmd/dev                       # server on :8080, provider on :9100
$ open http://127.0.0.1:8080/console/      # Continue with single sign-on
```

To sign in from the Vite development server instead, start slopscale with
its public URL set to Vite's origin, so the provider sends the browser back
there: `go run ./cmd/dev -server-url http://localhost:5173`, then
`bun run dev` in `web/` and open `http://localhost:5173/console/`.

`make test-e2e` runs the same flow in a browser: it builds the console,
starts `cmd/dev`, signs in through the mock provider, checks the audit log
and signs out (Playwright, `web/e2e/`). The Go side is covered by the
`TestConsoleLogin*` tests in `hscontrol/servertest`, which drive a mock
provider without a browser.

## Serving behind a reverse proxy

The console lives under `/console/` on the same origin as the API, so a
[reverse proxy](/slopscale/ref/integration/reverse-proxy) that forwards the whole host
needs no extra rules. It sends a `Content-Security-Policy` that allows its own
origin, `'wasm-unsafe-eval'` for the in-browser client, and `connect-src wss:`
so the client can reach DERP relays over WebSockets; do not embed it in another
site's frame.

If you prefer the console not to be reachable, block `/console/` and `/admin/`
at the proxy.
Everything it does is also available through the API with the same key, so
this only removes the page, not the capability.
