By default the OAuth flow is deliberately public: it exists so that Claude Code’s discovery handshake completes, and there are no real credentials behind it. Anyone who can reach the server’s address can use every tool it exposes, so access control has to come from the network layer. Do not expose an unauthenticated server to the public internet. Run it on a private network, or an overlay network such as Tailscale, so that only devices you trust can reach it.
When the server has to be reachable from outside such a network, turn on authentication instead.
Treat the server as having the same reach as its most permissive vault: whatever the process user can read and write in a configured vault, a connected client can too.
Setting auth in the config file makes the
server check a signed token from an OAuth service you choose, for users you
list, on every call to /mcp. It is the only thing that makes it reasonable to
put the server on a public address. What it does, and does not, give you:
users or readOnlyUsers, holding
an unexpired token from the configured issuer, made for this server’s
audience. A token for another service, another user or another audience is
refused. The algorithm is limited to RS256 and ES256, so an unsigned token or
one signed with a symmetric algorithm is refused.users can do everything the server can:
read, write and delete in every configured vault, subject to denyPaths and
denyBinaryPaths. A user in readOnlyUsers cannot change anything and cannot
use query-graph. Keep the users list to the people you would hand the
vault to.PUT /up/<token> is not behind a token. The one-time upload URL is its
own credential: 256 random bits, valid for one request, tied to a fixed size,
hash and destination (see upload-file). A
proxy that exposes the server can publish /up/ and /mcp separately.query-graph answers without applying denyPaths (below). That matters
more once the server is public: leave the tool to users you trust fully, or do
not build a graph over folders you have denied.mcpBaseUrl is https, or is a loopback
address and the server listens on loopback too. Terminate TLS in front of it:
see the install pages.userClaim is sub. If you identify users
by email, a token whose email_verified is false is refused, but a service
that lets anyone register an address without verifying it would let them be
you. Use an issuer that verifies addresses, or sub.GET /mcp
stream was authenticated when it was opened and carries only notifications,
which the server does not currently send. Its token expiring does not close
it.expired, wrong audience,
user not allowed…) and, once the signature has verified, the user. A token
is never logged.The config file’s denyPaths blocks specific
folders, globally or per vault, and is enforced in the server before a tool
touches any file. It is a coarse guard, not a substitute for network access
control, and it has one exception: query-graph answers from a graphify
knowledge graph without consulting the deny list, so a graph built over denied
folders can reveal their content. Build the graph from a vault without that
content, or don’t use the tool.
denyBinaryPaths is a
narrower, read-only guard for binary files. It stops read-file
returning a protected file’s bytes, and stops move-file (and
move-note, for a non-markdown file) moving a protected file out of protection,
so the agent can’t relocate a file to read it. .trash is protected along with
it, and read-note refuses a protected non-markdown file. It doesn’t affect
markdown notes, and it doesn’t hide names: list-files still lists a protected
file, with its modification time and size. To find protected files, start with
list-files.
A path scope that is a symbolic link, or passes through one, is not followed:
those tools list nothing for it.
Symbolic links are handled differently by different tools. read-file
resolves a link and checks the real location: it refuses a link that points
outside the vault, and applies denyPaths and denyBinaryPaths to the target.
The other tools, including read-note, judge a path as the client gives it and
read through a link. If the process user can read the target, a connected client
can read it with read-note by way of a symlink in the vault, whatever the deny
lists say. Don’t put symlinks to sensitive files inside a served vault.
Other protections that apply to every request:
redirect_uri in the OAuth flow is restricted to loopback addresses, to
prevent open-redirect abuse.Origin header is checked against loopback, to prevent DNS-rebinding
attacks on the local port.isError results, with the vault’s path prefix
removed from the message. Other absolute paths that appear in an error, such
as output from an external command, are not rewritten.expectedMtime precondition
so an edit never silently overwrites a change made in between.fetch-fileThis tool makes the server perform an outbound request to a URL chosen by the caller. On any deployment, the host it runs on may be able to reach private network services that a remote client should never be able to reach. Treating that as the main risk, the tool:
http and https URLs;::ffff:127.0.0.1 is unwrapped and
checked as the IPv4 address it contains, in either notation;maxBytes;timeoutMs in total;read-fileThis tool is read-only and makes no network request or subprocess call. It
checks the path against denyPaths and denyBinaryPaths before any filesystem
access, and checks the file’s size against readMaxBytes using stat before
reading it, so an oversized or protected file is never loaded into memory.
Symbolic links are resolved first, and only regular files are returned. The
denyBinaryPaths matcher runs in time bounded by the pattern and path lengths,
so a client can’t slow the server with a crafted path. The size limit matters
because the whole file is base64-encoded into one response, which is a third
larger than the file itself.
upload-fileThis tool reserves a one-time URL, and a file sent to it ends up in the vault, so the URL has to be hard to misuse. It is built like this:
uploadTtlSeconds. A person who has seen a URL
therefore gets one attempt, not several. The cost is that they can make the
agent’s upload fail once, and the agent recovers by asking for a new URL.create-file does not do this.)Origin header are refused, so a web page can’t use a
URL.uploadStallSeconds (30 by default) is cut off, so one
client can’t fill the disk or exhaust the server’s file handles. One
trade-off: Node’s whole-request time limit can’t be set per request, so the
server raises it for every request, enough for the largest permitted upload to
finish at 50 KB/s and never beyond an hour. That makes a slow client on any
endpoint hold a connection longer than the default five minutes./up/<redacted>.What it doesn’t do: it is not authentication. The server has no real credentials (see Who can reach the server), so anyone who can call its tools can reserve a URL. The token limits one upload to one destination; it doesn’t decide who may upload. The URL also appears in the tool’s result, so it is visible to the model and in the conversation transcript, which is why it is short-lived and works only once.
An upload does not have to come from the address that reserved its URL: the
check is off by default, because the call and the curl often leave from
unrelated networks (a vendor’s cloud and your own machine). What protects the URL
is that it works once, briefly, for one destination, and for a file that matches
the SHA-256 and size declared when it was reserved, so anyone who gets it can
only upload those exact bytes or use it up. The audit log records the address of
the call (reserved_from) and of the upload (addr) for every placed file.
uploadNetworkPrefix turns the
address check on: 32 for the same address, 16 to 31 for the same IPv4 network.
Callers behind the same NAT share an address, and behind a reverse proxy the
check does nothing unless
trustedProxies is set. Set it
only to proxies you run, because the X-Forwarded-For header from anywhere else
is ignored; if the server can be reached directly as well as through the proxy,
a forged header would otherwise defeat the check.
replace-fileThis tool reserves the same kind of one-time URL, and everything above applies:
the token, the single use, the fixed destination, size and hash, the Origin
refusal, the staging folder and the symbolic-link handling. What differs is that
it overwrites, so it has to be hard to use on the wrong file:
.trash just before the swap, so an edit made after the
agent read the file is refused rather than lost..trash, not deleted, so a wrong replacement can be
undone, until the server’s purge removes it
(30 days by default)..trash, which refuses a second replacement that raced the
first. A program that writes to the old file in place while the server hashes
it is not stopped (that would need file locking), but is noticed afterwards
and reported in the reply and the log.list-notes and list-files show anyway.)denyBinaryPaths cannot be replaced, because the hash check would
be a way to test guesses at the content of a file that read-file
refuses to show.upload-reserved with mode=replace)
and the replacement (upload-placed with mode=replace and previous_sha256).The server can keep a record of who did what. Every tool call, every login and
refused login, and every upload is logged with the user, the caller’s address
and the host the request named; see Logging and the audit
log. Set logFile to keep it in a
file of its own. It never holds a token, an upload URL, the text of a note or a
search query. It does hold user names, addresses and the paths a call touched,
so the file is created readable by its owner only and should be kept that way.
The log is a record for you; it doesn’t stop anything.
If you think you have found a security problem, please raise it through GitHub issues. Describe the problem without including a working exploit.