Flat vector illustration of a laptop and phone connected through a mesh network guarded by a padlock node

Locking down Moonraker with API keys: the step-by-step guide

Locking down Moonraker with API keys: the step-by-step guide

Companion to "Castle walls vs. API keys." That article argues the case. This one is the wrench time.

Most Klipper installs ship with Moonraker's API-key machinery already on — and then neuter it with a trusted_clients list that trusts the entire local network. This guide gets you from "the checkbox is technically enabled" to "an unauthenticated request gets a 401, full stop," with the curl commands to prove it.

What you need before starting:

  • SSH access to your printer host (the Pi, Orange Pi, whatever runs Klipper)
  • Your moonraker.conf location — on MainsailOS, FluiddPi, and KIAUH installs it's ~/printer_data/config/moonraker.conf. Stock installs use ~/moonraker.conf.
  • About 20 minutes

What the API key actually protects

Moonraker is the web server that sits in front of Klipper. Everything you do in Mainsail or Fluidd — uploading G-code, starting prints, editing printer.cfg, killing power to the PSU, rebooting the Pi — is a Moonraker API call. The API key gates all of it: the HTTP endpoints and the JSON-RPC/websocket API behind them.

Concretely, an unauthenticated outsider hitting a properly locked-down Moonraker cannot:

  • Read or write your config files
  • Upload, download, or delete G-code
  • Start, stop, or pause prints
  • Query printer status, history, or job queues
  • Run machine commands (shutdown, reboot, service restarts, sudo-backed power device toggles)
  • Manage user accounts or read the API key itself (/access/api_key is a protected endpoint)
  • Manage webcam configuration (/server/webcams/*)

What it does not protect — covered honestly in the last section: the actual camera pixels, SSH, and every other service on the box that isn't Moonraker.

How Moonraker decides who you are

Every request runs through this check, in this order (straight from moonraker/components/authorization.py):

  1. JSON Web Token — the Authorization: Bearer token a browser gets when you log into Mainsail/Fluidd with a username and password.
  2. Oneshot token — a ?token= query-string token, valid for 5 seconds and one use. Exists because JavaScript can't set headers on websocket and download requests.
  3. API key — the X-Api-Key header. Compared with a constant-time comparison (hmac.compare_digest) to the stored key. Mismatch → 401.
  4. force_logins — if enabled and at least one user exists, logins are required and trusted-IP bypass is overridden.
  5. Trusted connection — is your source IP in trusted_clients? If yes, you're in with no credentials at all.
  6. Otherwise: 401 Unauthorized.

The part most people miss is #5: a trusted client bypasses the API key completely. The key is not a second factor on top of IP trust; it's the fallback for clients that aren't trusted. If your trusted_clients list covers the whole LAN, the key is decoration.

The key itself is a 32-character hex string (uuid4().hex), generated on first start and stored in Moonraker's database — ~/printer_data/database/moonraker-sql.db, in the authorized_users table as the internal user _API_KEY_USER_. It survives restarts. Deleting the database regenerates it.

Step 1: Look at what you have now

SSH in and find your [authorization] section:

ssh pi@printer.local
grep -A 12 '\[authorization\]' ~/printer_data/config/moonraker.conf

Here's what came back on one of our shop printers, running Moonraker v0.10.0:

[authorization]

trusted_clients:
    192.168.0.0/16
    10.0.0.0/8
    127.0.0.0/8
    169.254.0.0/16
    172.16.0.0/12
    FE80::/10
    ::1/128
cors_domains:
    *.lan

That's the stock MainsailOS-style default, and it means: every private IPv4 address on Earth is trusted. Any phone, laptop, or IoT gadget on the network gets full, keyless, passwordless access to the printer — including the "reboot the Pi" and "edit config" APIs. This is why the companion article says the API key on a default install is a language nobody is forced to speak.

We're going to fix that.

Step 2: Tighten trusted_clients

Edit ~/printer_data/config/moonraker.conf (you can do this from the Mainsail/Fluidd Machine tab too) and replace the wide-open list with the smallest set that keeps your own stuff working:

[authorization]
trusted_clients:
    192.168.50.0/24
    127.0.0.1

Rules of the road, straight from Moonraker's configuration docs:

  • Entries are single IPs, CIDR ranges, or fully-qualified domain names. Both IPv4 and IPv6 work.
  • For IPv4 CIDR ranges, the last octet must be 0 — 192.168.50.0/24, never 192.168.50.5/24. A malformed CIDR gets rejected with a config warning.
  • A range ending in /24 covers .1 through .254 of that subnet.
  • Prefer IP ranges over domain names. Domain entries force a DNS lookup on every request; if DNS hiccups, auth fails and the request is refused.
  • Never put 0.0.0.0/0 in there. That's "trust everyone," which is the opposite of a trust list.

Why keep 127.0.0.1? Because helper services that run on the printer itself — moonraker-obico, OctoEverywhere's companion, Klipper's own tooling — talk to Moonraker from loopback. If you drop 127.0.0.1, those stop working the moment they need credentials they don't have. (Some of them also have their own pairing flows that create a Moonraker user; 127.0.0.1 keeps both patterns alive.)

If your printer serves anything to other fixed devices — a showroom kiosk, a second slicer box — add those exact IPs, not a broad range.

Also set this while you're in there:

[server]
use_xheaders: False

use_xheaders defaults to True, which tells Moonraker to take the client's IP from the X-Forwarded-For / X-Real-IP headers — the right behavior when Moonraker sits behind a reverse proxy, and the wrong behavior when clients talk to it directly, because then any client can claim any source IP by setting a header. Moonraker does cross-check: when a request arrives with proxy headers, both the claimed IP and the real socket IP must be trusted, or you get a 401 (Proxy address X not trusted). So a spoofed X-Forwarded-For won't defeat a properly tightened list. But there's no reason to run header-parsing on a directly exposed server — set it False unless you've deliberately put Moonraker behind nginx. If you do proxy Moonraker, leave it True and make sure only the proxy's IP is in trusted_clients.

Step 3: Decide between key auth and login auth

Two ways to authenticate a remote client. Pick per device:

  • User accounts (JWT): what Mainsail and Fluidd give you when you create a login. The browser logs in, gets a token that expires after 1 hour plus a refresh token, and re-authenticates silently. This is the right choice for humans at keyboards, and it's per-user — you can have an admin and a guest.
  • API key (X-Api-Key header): the right choice for headless clients — scripts, Home Assistant, curl, slicer integrations, phone apps that don't do Moonraker's login flow.

If you want to force the issue — make IP trust worthless and require real credentials from everyone once any user exists — add:

[authorization]
force_logins: True

force_logins only kicks in once at least one user has been created (create the first user from a trusted connection, e.g. from the Pi itself or your now-narrowed LAN, via POST /access/user or the frontend's user menu). While it's on, trusted-IP bypass is overridden: even 127.0.0.1 needs a token. That's the strictest posture; most home shops don't need it, but it's there, it's documented, and it works.

Two related knobs you'll see in the [authorization] spec:

  • login_timeout — days until a logged-out user must re-enter credentials. Default 90.
  • max_login_attempts — failed logins allowed per IP before lockout (locks reset on Moonraker restart). Default: unlimited. If your Moonraker is ever reachable beyond your LAN, set this to something small like 5.

cors_domains you can usually leave alone. It defaults to disabled, and browser frontends served by the printer's own nginx (Mainsail at http://printer.local) are same-origin and don't need it. Only add entries if you load a frontend from a different origin, and never use a wildcard in the top-level domain (http://my-printer.* is rejected by Moonraker's config parser).

Step 4: Find your key, then restart Moonraker

Read the current key (must be done from a trusted client or with credentials — it's a protected endpoint):

curl -s http://127.0.0.1:7125/access/api_key

Response (Moonraker wraps string results in a result object — this is verbatim from v0.10):

{"result": "e514851f37b94c779d955212b6906f95"}

Then restart Moonraker so the config changes load:

sudo systemctl restart moonraker

or hit Power → Restart Moonraker in Mainsail/Fluidd.

Step 5: Rotate the key without downtime

Sharing a key (with a friend, a plugin, a screenshot) and want it dead? One request:

curl -s -X POST -H "X-Api-Key: <current-key>" http://printer.local:7125/access/api_key
{"result": "4f7c0f04a1e342e5a3c65f11a0f7d9b2"}

Per the docs: the change applies immediately — every untrusted client needs the new key on its next request. Two operational notes:

  • Already-open websockets stay authenticated on the old key until they disconnect. Rotating does not instantly kick a live Mainsail tab; it just blocks new connections on the old key.
  • Update every consumer of the key right after rotating, or they'll start getting 401s.

If you'd rather never handle the raw key in a shell, Mainsail and Fluidd can create user accounts instead, and scripts can use the oneshot-token endpoint (next section).

How the common clients consume it

  • Mainsail / Fluidd — humans should use user accounts (the login icon in the UI). Both frontends store your credentials locally and keep a JWT refreshed. Both also offer API-key-based connection modes in their settings if you're running them remotely without accounts.
  • moonraker-obico, OctoEverywhere — these run on the printer host itself and connect over loopback/the unix socket, which is why 127.0.0.1 belongs in trusted_clients. Their public-facing side is their own cloud relay with its own pairing; your API key never leaves your LAN for them.
  • Home Assistant, scripts, curl — the X-Api-Key header, exactly as shown above.
  • Websocket clients — pass the key (or a JWT) in the identify_connection call after connecting, or mint a oneshot token first — GET /access/oneshot_token returns a token valid for 5 seconds and one use, safe to paste into a query string where headers aren't possible:
TOKEN=$(curl -s -H "X-Api-Key: <key>" http://printer.local:7125/access/oneshot_token | sed -E 's/.*"result": "([^"]+)".*/\1/')
curl -s "http://printer.local:7125/server/info?token=$TOKEN"

We ran that exact flow against a live printer: token minted, HTTP 200 with the token in the query string. Five seconds later the token is worthless.

Testing: prove it actually works

Don't trust the config — curl it. Four checks, run from a machine that is not in trusted_clients (your phone on cellular via a tunnel, or any box outside the printer's subnet):

1. Is my connection trusted? /access/info is public by design and tells you exactly what Moonraker thinks of you:

$ curl -s http://printer.local:7125/access/info
{"result": {"default_source": "moonraker", "available_sources": ["moonraker"], "login_required": false, "trusted": false}}

"trusted": false is what you want to see from outside your trust list. If it says true, stop and fix trusted_clients — nothing else in this section will behave as described.

2. Knock without a key:

$ curl -s http://printer.local:7125/server/info
{"error": {"code": 401, "message": "Unauthorized", "traceback": "Traceback (most recent call last): ... tornado.web.HTTPError: HTTP 401: Unauthorized (Unauthorized)"}}

HTTP status 401. (The traceback field is present on this Moonraker build; older versions return the same error without it.)

3. Same request, with the key:

$ curl -s -H "X-Api-Key: e514851f37b94c779d955212b6906f95" http://printer.local:7125/server/info
{"result": {"klippy_connected": true, "klippy_state": "ready", "components": ["authorization", "database", "file_manager", "machine", "update_manager", "..."], "moonraker_version": "v0.10.0-20-g9008485", "api_version_string": "1.5.0"}}

HTTP 200. That's the whole test: no key → 401, key → 200. We verified the 401 and the oneshot-token flow verbatim against a live v0.10 printer; the 200-with-key path follows the documented header contract (X-Api-Key, checked with a constant-time comparison — verified in Moonraker's authorization.py).

4. Rotate and re-test. POST /access/api_key, then re-run check 2 with the old key. It must now 401. If it doesn't, a client cached something — find it before you consider this done.

One more spoof check while you're at it, because it's educational:

$ curl -s -H "X-Forwarded-For: 127.0.0.1" http://printer.local:7125/server/info -o /dev/null -w "%{http_code}\n"

If Moonraker is directly reachable (no proxy) and use_xheaders is True, this returns 200 on a default config — you just claimed to be a trusted loopback client with a header. It does not work against a tightened config (Moonraker independently checks the real socket IP and rejects untrusted "proxies"), but it's a live demonstration of why use_xheaders: False belongs on any directly exposed server, and why "trust the whole RFC1918 range" is the config that makes every other setting decorative.

Common mistakes

1. trusted_clients wide open. The default MainsailOS-style list (all of 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, ...) shown in Step 1 is the single most common reason API keys "don't work": they're never consulted, because every LAN device is trusted first. Trust your subnet, not the world.

2. Putting the key in the URL. Query strings end up in browser history, server logs, and proxy logs. The key goes in the X-Api-Key header — that's what it's for. The only token that belongs in a query string is the 5-second, single-use oneshot token, which exists precisely because it expires before logs matter.

3. Forgetting the webcam is a separate door. This one surprises people. Moonraker's webcam component manages webcam configuration — the /server/webcams/* endpoints, which are auth-protected like everything else. Moonraker never serves the camera pixels. The stream comes from a separate service — crowsnest with camera-streamer, mjpeg-streamer, go2rtc — listening on its own port (8080, 8554, or similar), and in typical Mainsail/Fluidd installs the printer's nginx proxies same-origin paths like /webcam/?action=stream straight to it with no authentication in the path. Consequences:

  • Locking Moonraker does nothing for the camera. Anyone on the LAN (or anyone you've forwarded a camera port to) can watch.
  • Fix it at the camera service: bind it to localhost and let only the authenticated frontend reach it, keep camera ports off any port-forward list, and read your streaming service's auth options (camera-streamer and go2rtc both have some; mjpeg-streamer effectively has none — prefer one that does).
  • Or simply don't expose the camera beyond the LAN until it's behind the VPN in the companion guide.

4. Screenshots and commits. Mainsail and Fluidd settings pages will happily display your key. Don't post those screenshots; don't commit moonraker.conf with the key anywhere public. If a key was shared — even "just for a second" — rotate it (Step 5, one request, instant).

5. Testing from a trusted machine. If you validate from a laptop that's inside trusted_clients, every request returns 200 and you'll conclude you're secure when you've tested nothing. Test from a vantage point that Moonraker doesn't trust — check 1 above exists exactly to catch this.

6. Restarting the wrong service. Config edits to moonraker.conf require a Moonraker restart, not a Klipper restart. sudo systemctl restart moonraker.

What API keys do not protect

Honesty section, same spirit as the companion article:

  • The camera stream. Separate service, separate port, separate (often absent) auth. See mistake 3.
  • Anything else listening on the box. SSH, Samba, OctoPrint-in-law services, the klipper host's own API server if you've ever exposed it. Moonraker's key speaks for Moonraker only. (sudo ss -tlnp on the Pi is a sobering five-second audit.)
  • A leaked key. The key is a bearer token: whoever holds it is you, from anywhere. There's no "was this really Rob's laptop?" check. Rotate what you share.
  • Plain HTTP. Over unencrypted HTTP the key crosses the wire in the clear — the coffee-shop Wi-Fi, the compromised router, the relay in between all see it. HTTPS or a VPN, or both.
  • Physical access. SD card out, key's on it. Different threat model; no config option fixes it.
  • Software bugs. Auth is a layer, not a patch policy. Update the stack.

That last list is exactly why the companion guide exists: Tailscale makes the printer undiscoverable and unreachable from outside your private network, and the API key makes the conversation unanswerable for anything that somehow gets there anyway. Walls plus language. The VPN guide — install, subnet routing vs. direct install, and why it complements rather than replaces this one — is here: /blog/remote-series/tailscale-remote-access.

If you read the castle article first: this guide was the "how," that one was the "why," and the Tailscale guide is the "also, remove the target."

Sources

  • [Moonraker: Configuration — [authorization]](https://moonraker.readthedocs.io/en/latest/configuration/) — option semantics: enable_api_key, trusted_clients (CIDR rules), force_logins, cors_domains wildcard rules, login_timeout, max_login_attempts
  • Moonraker: Authorization and Authentication (external API) — X-Api-Key header contract, /access/api_key GET/POST, oneshot tokens (5 s, single use), immediate rotation semantics
  • Moonraker source: components/authorization.py — auth order of checks, constant-time key comparison, uuid4().hex key generation, _API_KEY_USER_ storage, trusted-proxy cross-check
  • Moonraker source: components/webcam.py — webcam component registers config-management endpoints only; no stream proxying in Moonraker
  • Moonraker: Installation — data folder layout — ~/printer_data/config/moonraker.conf, ~/printer_data/database/moonraker-sql.db
  • [Moonraker: Configuration — [server]](https://moonraker.readthedocs.io/en/latest/configuration/) — use_xheaders semantics and default
  • Live verification 2026-10-23 against two shop printers running Moonraker v0.10.0-20: default-config trusted: true behavior, 401 body, oneshot-token flow, X-Forwarded-For behavior on both default and tightened trust lists

Quick poll: what's remote monitoring worth to you?

Three questions, one tap each. We read every answer — it shapes what we build and cover next.

Votes are anonymous and stored on your device only. No account, no tracking.
Gear for remote monitoring
As an Amazon Associate, Advanced 3D Printing earns from qualifying purchases. Prices/availability checked Oct 24, 2026.
Back to blog

Leave a comment

Please note, comments need to be approved before they are published.