<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: aliasunder</title>
    <description>The latest articles on DEV Community by aliasunder (@aliasunder).</description>
    <link>https://dev.to/aliasunder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2121498%2F9a490613-dc34-4f12-8786-72d783e73538.png</url>
      <title>DEV Community: aliasunder</title>
      <link>https://dev.to/aliasunder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aliasunder"/>
    <language>en</language>
    <item>
      <title>OAuth 2.1 for a Personal MCP Server: What Three Months in Production Changed</title>
      <dc:creator>aliasunder</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:51:55 +0000</pubDate>
      <link>https://dev.to/aliasunder/oauth-21-for-a-personal-mcp-server-what-three-months-in-production-changed-3kj7</link>
      <guid>https://dev.to/aliasunder/oauth-21-for-a-personal-mcp-server-what-three-months-in-production-changed-3kj7</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/aliasunder/vault-cortex" rel="noopener noreferrer"&gt;Vault Cortex&lt;/a&gt; is an open-source MCP server that gives Claude read and write access to my Obsidian vault from any device. The vault is my entire second brain, and the server sits on the public internet so that claude.ai on my phone can reach it. That requirement rules out a static token on its own, because claude.ai's connector model for an individual account offers OAuth or nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.bluerock.io/use-cases/safely-adopt-mcp" rel="noopener noreferrer"&gt;One 2026 analysis&lt;/a&gt; found that 41% of MCP servers ship with no authentication at all, and only 8.5% implement OAuth. This post covers the auth architecture of one server in the 8.5%: the threat model, each decision and the reasoning behind it, and what three months of production traffic changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Threat Model
&lt;/h2&gt;

&lt;p&gt;Vault Cortex is a single-user server. There's no tenant isolation to get wrong and no lateral movement to contain. But behind that one credential is every note I've written: code opinions, travel itineraries, the session history of every project I work on. Community plugins also store third-party API keys in &lt;code&gt;.obsidian/&lt;/code&gt; config files, which is why dot-prefixed paths like that folder are blocked from every vault operation; a leaked token shouldn't hand over a second set of secrets on top of the notes. A breach exposes all of it at once, so the design treats one user's data as high-value.&lt;/p&gt;

&lt;p&gt;The server also runs in two deployment shapes. My reference deployment is an Express server in a Docker container on an AWS Lightsail VPS. In front of it sits, in order, a Cloudflare-proxied custom domain, API Gateway with a Lambda authorizer, and a Cloudflare Tunnel to the container's origin. Cloudflare's WAF and DDoS protection sit on the public endpoint, the gateway's default &lt;code&gt;execute-api&lt;/code&gt; hostname is disabled so the proxied domain is the only way in, and Cloudflare Access restricts the tunnel to gateway traffic. The one-click Render and Railway deploys run with nothing in front: the container authenticates every request itself. Whatever the container does on its own has to be sufficient, and whatever the gateway adds is extra.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reference (AWS):  client → Cloudflare (proxied domain, WAF)
                         → API Gateway (Lambda authorizer)
                         → Cloudflare Tunnel (Access-locked)
                         → container (Express)
One-click:        client → container (Express)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this scale, the flow the MCP spec describes is enough, because the threats a single-user server faces are the ones OAuth 2.1 already handles: an access token that leaks stops working at its 6-hour expiry, a stranger who finds the endpoint can register a client but can't get past the consent page without the static token, and rotating that one token revokes every JWT and refresh token at once. The part of OAuth it leaves out, scopes that partition privilege between users, is the part a one-user server has no use for. The production numbers come later, but the summary is that across every 30-day log window I've audited, nothing unauthenticated from the internet has reached the server itself. One user and nine registered clients (Claude apps and CLI tools across my devices) is not a stress test, so the zeros in this post show the auth stack running correctly unattended; they don't show it surviving an adversary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Credentials, Two Kinds of Client
&lt;/h2&gt;

&lt;p&gt;OAuth was the plan from the start. A public server with write access to my notes needs tokens that expire and can be revoked, and a static key gives you neither. claude.ai's connector model removed the alternative anyway: on an individual account there is no way to set up a manual connector with a static token. (A request-header credential &lt;a href="https://claude.com/docs/connectors/custom/remote-mcp#authenticating-with-request-headers" rel="noopener noreferrer"&gt;exists in beta&lt;/a&gt; for a limited set of organizations, entered once by an admin and shared by everyone in the org, which is the opposite of what a personal server needs.)&lt;/p&gt;

&lt;p&gt;I kept a static bearer token alongside OAuth because the clients fall into two groups. Browser-capable clients (Claude Desktop, Claude Code, claude.ai) use OAuth 2.1 with Authorization Code + PKCE, which gives them tokens that expire and rotate and leaves no secret sitting in a config file after the initial consent. CLI tools, MCP Inspector, curl, and automation use the static token, because there's no browser to complete a flow, and a value in a config file or an environment variable is what those tools expect. Browser clients still enter the static token once, at the consent page; after that they hold only their own tokens.&lt;/p&gt;

&lt;p&gt;The static token never expires, while OAuth access tokens live 6 hours. Since it's the key every JWT is signed with and the key the refresh-token rows are stored under, rotating the static token ends every session at once. The moment the new secret is live, every issued JWT fails its signature check and every stored refresh token becomes unreachable. Between rotations, an access token is revoked early only if its client revokes it or its refresh token is caught being reused, so a leaked JWT that nobody has noticed works until it expires. The access token lifetime is the only limit on that window, and the logs show that shortening it is nearly free. In 59 days of logs, none of the logged 75 silent refreshes across 9 clients produced a visible interruption, so I lowered the lifetime from 24 hours to 6. One hour would put a refresh inside almost every working session for limited benefit. Because the static token signs every JWT, the one credential that never expires is the one a leak would hurt most, and the refresh token section explains why I kept it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the MCP Spec Requires
&lt;/h2&gt;

&lt;p&gt;Authorization is optional in the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;MCP specification&lt;/a&gt;, so a server with no authentication at all still conforms. When a server does authenticate over HTTP, the MCP spec's answer is &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/" rel="noopener noreferrer"&gt;OAuth 2.1&lt;/a&gt; (still an IETF draft, in its fifteenth revision as I write this). Most walkthroughs of that flow are written by companies selling a hosted authorization server, so they describe the parts you hand off. This is what the flow looks like when your own server is the authorization server. Condensed from the auth section of the repo's &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#auth-oauth-21--defense-in-depth" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;, it's nine steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. POST /mcp (no token)                        → 401, WWW-Authenticate points at discovery
2. GET /.well-known/oauth-protected-resource   → where the authorization server lives
   (also served at .../oauth-protected-resource/mcp, the RFC 9728 path-suffixed form)
3. GET /.well-known/oauth-authorization-server → the endpoint list
4. POST /register                              → dynamic client registration
5. GET /authorize?...&amp;amp;code_challenge=...       → consent page in the browser
6. user approves with the static token         → redirect back with an authorization code
7. POST /token (code + code_verifier)          → JWT access token + refresh token
8. POST /mcp (Authorization: Bearer &amp;lt;JWT&amp;gt;)     → real requests
9. POST /token (refresh_token) on expiry       → new JWT, no browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4 is dynamic client registration: you don't pre-register Claude as a client. On first connect the client sends its own name and redirect URIs to &lt;code&gt;/register&lt;/code&gt; and gets a client ID back. The July 2026 revision of the MCP spec deprecates dynamic registration, keeping it only for backwards compatibility, and recommends &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents" rel="noopener noreferrer"&gt;Client ID Metadata Documents&lt;/a&gt; instead, where a client identifies itself by a URL it hosts. The SDK's server side doesn't support those documents yet, so this server still registers clients dynamically. For this server, the benefit would be at registration: a client identified by a URL it hosts needs no row written at registration, and that unauthenticated write is what the rate-limiting section is about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88ff620ycc5afxcvcg2k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88ff620ycc5afxcvcg2k.png" alt="The nine-step OAuth 2.1 flow between an MCP client and Vault Cortex" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The flow at a glance. The only human step is the consent click.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/authorize&lt;/code&gt; is a public page, so the consent step is where the access control happens: the page requires the static bearer token, and approving means proving you already hold the server's secret. That's also what makes open dynamic client registration safe here. Anyone can register a client, but a registration that never passes consent never returns a token, and a registered &lt;code&gt;redirect_uri&lt;/code&gt; has to match exactly at authorization time (the SDK relaxes only the port, for &lt;a href="https://www.rfc-editor.org/rfc/rfc8252" rel="noopener noreferrer"&gt;RFC 8252&lt;/a&gt; loopback clients), so a registration can't redirect a real user's authorization code elsewhere. The residual attack is social: anyone can register a client with a familiar name and their own redirect URI, and the consent page will display whatever name they picked. The limit is that approving takes the static token and there's only one server operator: the only consent page I should ever see is the one I just caused to open, so an authorize link arriving any other way is an obvious signal something is not quite right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcemormxdu96yx7rp6kuj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcemormxdu96yx7rp6kuj.png" alt="The consent page with application name, client ID, requested scopes, a token field, and Deny and Approve buttons" width="800" height="758"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The consent page. The token field is the gate: approving proves you hold the server's secret.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One consequence follows from the MCP spec: the discovery endpoints have to be unauthenticated, because a client that can't discover your auth server can't authenticate, and that routing constraint comes back in the section on API Gateway. The TypeScript MCP SDK, for its part, ships the OAuth router, the &lt;code&gt;requireBearerAuth&lt;/code&gt; middleware, and per-endpoint rate-limit defaults. The provider you plug into them is the part you write: token issuance and verification, the SQLite token store, refresh rotation, the consent page, and revocation.&lt;/p&gt;

&lt;p&gt;In practice the flow is mostly invisible. Over 59 days of production logs ending August 22, I counted 8 consent flows in the browser; every other token issued in that window, about 90% of them, came from a silent refresh. The access token itself carries six claims: &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;iat&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verifying Twice
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/DEPLOY.md" rel="noopener noreferrer"&gt;reference deployment&lt;/a&gt;, every &lt;code&gt;/mcp&lt;/code&gt; request is validated twice independently. API Gateway's Lambda authorizer checks the Bearer token at the edge, then Express checks it again with the SDK's &lt;code&gt;requireBearerAuth&lt;/code&gt;. Both verify the JWT against the same HMAC secret and share nothing else: no session store, no introspection endpoint, and no network hop between validators.&lt;/p&gt;

&lt;p&gt;The two layers check different things on purpose. The Lambda is stateless: it validates the static token, or a JWT's signature, expiry, issuer, and audience, and that's all it can do without a database. The revocation list lives in SQLite on the container, so a revoked JWT passes the edge and is rejected by Express.&lt;/p&gt;

&lt;p&gt;At launch, API Gateway reached the container over the public internet: the instance's port 8000 was open on its public IP, and the gateway's origin URL pointed at it. Anything that found the IP could skip the gateway, so the container had to reject a bad token on its own. I've since closed the port entirely (API Gateway reaches the container only through the Access-restricted Cloudflare Tunnel, and admin traffic goes over Tailscale), and the Express layer is still required: it's the only layer the Render and Railway deploys have. The one route neither layer checks is &lt;code&gt;/healthz&lt;/code&gt;, because docker-compose healthchecks don't carry a token.&lt;/p&gt;

&lt;p&gt;The logs show the layering doing its job. The audit covered two non-overlapping 30-day windows (my CloudWatch retention was 30 days at the time, so the eras were audited separately). The unattributed traffic added up to 17 requests from 9 IPs (Cloudflare's own scanner, browser favicon fetches, a few curls), and the edge rejected every one of them. The only rejections at the Express layer were two verification probes I sent over Tailscale, deliberately behind the gateway, with a bad token: the gateway and the Lambda never saw them, and Express rejected them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Rejected&lt;/th&gt;
&lt;th&gt;What they were&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;about 1,160&lt;/td&gt;
&lt;td&gt;4xx responses: roughly a hundred auth-related (including all 17 requests from the 9 unknown IPs), and the rest healthy clients' transport noise, mostly expired-session retries, SSE probes, and discovery 404s from before the suffixed route existed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda authorizer&lt;/td&gt;
&lt;td&gt;0 of 23,619&lt;/td&gt;
&lt;td&gt;Every invocation that reached it carried a valid token (a tokenless request gets the gateway's 401 and never invokes it)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Bad-token probes I sent over Tailscale, behind the gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscbnxlz6zrgg0fxn2ui7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscbnxlz6zrgg0fxn2ui7.png" alt="Request path in the AWS reference deployment with rejection counts at each layer" width="800" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The reference deployment's request path, with rejection counts for the layers the audit covered (Cloudflare's WAF wasn't audited). The purple path is the container-only deploys: same Express auth, nothing in front of it.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The 30-Line Verifier
&lt;/h2&gt;

&lt;p&gt;The JWT code is one file with zero dependencies, and the verifier is about 30 lines of it. The risk in JWT handling is the feature surface a library brings, and the classic attacks (algorithm confusion and &lt;code&gt;alg: none&lt;/code&gt;) can't happen here: the verifier accepts HS256 only, computes the expected signature directly, and compares. There's no &lt;code&gt;alg&lt;/code&gt; header parsing for an attacker to manipulate, because nothing in the token changes how it's verified. I used Node built-ins to accomplish the task. The verifier uses &lt;code&gt;crypto.createHmac&lt;/code&gt; (OpenSSL underneath) over three base64url segments and &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; to compare them.&lt;/p&gt;

&lt;p&gt;My practical reasoning was the Lambda bundle. The authorizer imports &lt;code&gt;verifyJwt&lt;/code&gt;, and every dependency in that file grows the deployment package; a 200KB library for one algorithm didn't seem worth it. Here is the comparison logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sigBuf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expBuf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sigBuf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expBuf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sigBuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expBuf&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timingSafeEqual&lt;/code&gt; runs in constant time, so an attacker can't measure how much of a forged signature is right and iterate toward a valid one. The length pre-check is there because the function throws on unequal buffers, and a throw here would surface as a 500 instead of a 401. The timing side channel is worth closing even on a single-user server: the endpoint is public, and I can't assume nobody will probe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh Tokens: Four Versions
&lt;/h2&gt;

&lt;p&gt;Refresh tokens are where the design moved the most: four versions since launch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;What it cost existing clients&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Launch, May&lt;/td&gt;
&lt;td&gt;Rotated on every use, no expiry&lt;/td&gt;
&lt;td&gt;Nothing; re-auth only if the data volume was wiped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Three days after launch&lt;/td&gt;
&lt;td&gt;60-day sliding inactivity window; expired rows delete on read&lt;/td&gt;
&lt;td&gt;One re-auth for every active session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;August&lt;/td&gt;
&lt;td&gt;Rows keyed by &lt;code&gt;HMAC-SHA256(secret, token)&lt;/code&gt;, bound to the registering client; rotating the secret orphans every row&lt;/td&gt;
&lt;td&gt;One re-auth per client, all within 24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;August&lt;/td&gt;
&lt;td&gt;Reuse of a rotated refresh token revokes the whole grant: the client's refresh token and its access tokens&lt;/td&gt;
&lt;td&gt;Nothing, unless a token is replayed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under the launch version, a leaked refresh token that was never used stayed valid indefinitely. I then added a 60-day sliding window which fixed that: each use still rotates the token and now also extends the window, so a daily client never sees expiry and a dormant one is asked to consent again. Sixty days covers a multi-week trip with margin (the system was dogfooded on a 15-day trip, so that case wasn't hypothetical) and limits how long a leaked token stays useful while its client is idle. The migration set &lt;code&gt;expires_at INTEGER NOT NULL DEFAULT 0&lt;/code&gt;, treated every pre-migration row as already expired. This meant one forced re-auth for active sessions, rather than backfilling an expiry onto rows that were issued without one.&lt;/p&gt;

&lt;p&gt;The third version, in August, changed what the database holds. Until then it stored refresh tokens in plaintext, and rotating the static token, which also signs every JWT, didn't revoke them: a connected client would silently mint new JWTs under the new secret. Now each row is keyed by &lt;code&gt;HMAC-SHA256(secret, token)&lt;/code&gt;, so the database never holds a token anyone could present. Refresh tokens are also bound to the client that registered them, which the &lt;a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-4.3" rel="noopener noreferrer"&gt;OAuth 2.1 draft&lt;/a&gt; makes a MUST. The upgrade cleared four plaintext rows, the same one-time trade as the first migration. Secret rotation is covered by an integration test that boots the server under one secret, reboots it under another, and asserts that the old refresh token gets &lt;code&gt;invalid_grant&lt;/code&gt;, the old access token gets a 401, and a fresh consent succeeds.&lt;/p&gt;

&lt;p&gt;Using the static token as the signing key might look like the wrong choice: it's the credential that leaves the server (it gets pasted into config files and typed into the consent page), so a signing key that never leaves the server looks safer. But the static token by itself is already full API access on this server. An attacker holding it doesn't need to sign JWTs, so a second secret wouldn't remove a risk; it would add a second thing to rotate. In return, rotation revokes everything in one step: it invalidates the JWTs (signed with it) and orphans every refresh row (keyed by it), so no session survives a rotate and redeploy. It does mean every issued JWT is a sample of the key for an offline guesser, so the token can't be a password anyone chose: the CLI generates it as 32 random bytes (the manual guides say &lt;code&gt;openssl rand -hex 32&lt;/code&gt;), which puts brute force out of reach.&lt;/p&gt;

&lt;p&gt;The fourth version closed the reuse gap. Every refresh rotates the token, so a token presented a second time means either a stolen copy in play or a client that lost the response, and the &lt;a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-7.5.2" rel="noopener noreferrer"&gt;OAuth 2.1 draft&lt;/a&gt; has the server revoke the whole grant either way. Until this version, a replay got &lt;code&gt;invalid_grant&lt;/code&gt; and nothing else, because the row was deleted on first use. Now the replay costs the client its grant, and the next use asks for consent again.&lt;/p&gt;

&lt;p&gt;Over 59 days of logs under the second version there have been 75 silent refreshes across 9 clients, zero refresh failures, zero expiry-driven re-consents, and the longest gap between uses is 26 days. There's been one forced re-auth on record, when the data volume was wiped on purpose during local testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Production Changed
&lt;/h2&gt;

&lt;p&gt;The core OAuth 2.1 flow has held up in production. Once real clients were on it, four things prompted changes around the edges: a gateway setting turned 401 into a 403, which claude.ai reported as a connection issue on every add, a run of 404s observed from a discovery path that the &lt;a href="https://www.rfc-editor.org/info/rfc9728/" rel="noopener noreferrer"&gt;spec names&lt;/a&gt; and the server didn't serve, a rate limiter that trusted a header it shouldn't have, and a scanner report that, though mostly false positives, surfaced one real finding.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 403 claude.ai Reported as a Connection Issue
&lt;/h3&gt;

&lt;p&gt;Adding the server in claude.ai showed a "Connection issue" status instead of the connect prompt, while Claude Code and Claude Desktop worked. The connector could still be added and connected by hand, so this was a wrong status on every add rather than a lockout, and I wanted to know where the status came from. My first suspect was the new Cloudflare-proxied custom domain, so I isolated it: pointed requests at the gateway's raw execute-api URL with &lt;code&gt;--connect-to&lt;/code&gt; (that hostname was still open in June; it's disabled now), bypassing Cloudflare entirely, and got identical 403s, so I knew the domain wasn't the problem.&lt;/p&gt;

&lt;p&gt;The cause was a status code. MCP clients start the OAuth flow from a 401: no token means a 401, and the 401 is what starts discovery. When an AWS HTTP API's Lambda authorizer denies a request, the gateway answers with a 403, and HTTP APIs have &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html" rel="noopener noreferrer"&gt;no Gateway Responses&lt;/a&gt; to reshape it. The &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html" rel="noopener noreferrer"&gt;documented ways&lt;/a&gt; to get a 401 out of an HTTP API are a missing identity source, which makes the gateway reject the request before the Lambda runs, or the Lambda raising its own &lt;code&gt;Unauthorized&lt;/code&gt; error. Claude Code accepts either status. claude.ai requires the 401, and &lt;a href="https://claude.com/docs/connectors/building/lazy-authentication#return-401-not-a-tool-error" rel="noopener noreferrer"&gt;its connector docs say so&lt;/a&gt; in those words. The authorizer's identity source had been left empty on purpose, to let the unauthenticated discovery paths reach the Lambda. That meant the Lambda ran on every tokenless request, denied the ones to &lt;code&gt;/mcp&lt;/code&gt;, and the gateway turned each deny into a 403. It went unnoticed for a month because established connections never broke: refresh uses the open &lt;code&gt;/token&lt;/code&gt; route, so only a first tokenless request (a new connector, a fresh install) ever saw it.&lt;/p&gt;

&lt;p&gt;The fix was the identity source: registering the &lt;code&gt;Authorization&lt;/code&gt; header as the authorizer's identity source and splitting the routes, so tokenless requests to &lt;code&gt;/mcp&lt;/code&gt; get an automatic 401 from the gateway before the Lambda is invoked, and the discovery routes stay open as the MCP spec requires. A tokenless probe now costs no Lambda invocation at all. I considered a Cloudflare Worker rewriting 403 to 401 (patching the symptom) and migrating to AWS REST APIs for Gateway Responses (a larger migration than the problem justified), and rejected both. The status code is part of the API contract, and two clients reading the same MCP spec disagreed about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 404s That Were a Spec Gap
&lt;/h3&gt;

&lt;p&gt;The second finding came from a client that was following RFC 9728 more closely than my server was. In early August, Perplexity started getting 404s from the server, 63 of them in two days, and at that rate it looked like a misconfigured client. The paths said otherwise. Every request was for &lt;code&gt;/.well-known/oauth-protected-resource/mcp&lt;/code&gt;, the path-suffixed discovery URL that &lt;a href="https://www.rfc-editor.org/rfc/rfc9728" rel="noopener noreferrer"&gt;RFC 9728&lt;/a&gt; makes the canonical location for a resource served under a path, and the server only answered at the root document. I added the suffixed route, and the 404s stopped, and there have been none since.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limiting Behind a Proxy
&lt;/h3&gt;

&lt;p&gt;In August a security researcher reported, through GitHub's private vulnerability reporting, that the rate limiter keyed its bucket on the client-supplied &lt;code&gt;Forwarded&lt;/code&gt; header without checking that the header came from a trusted proxy. An attacker could open a fresh bucket per request, and since &lt;code&gt;/register&lt;/code&gt; is unauthenticated by design, the limiter was the only control in front of unbounded database writes. The report came with a working proof of concept against the stock Docker image: six control requests, the sixth got a 429; six spoofed requests, all six passed. That left twelve rows in the &lt;code&gt;clients&lt;/code&gt; table: one from a pre-test check, five from the control run, and six from the spoof.&lt;/p&gt;

&lt;p&gt;The bypass was older than the limit. Since launch day in May, the bucket key had trusted whatever &lt;code&gt;Forwarded&lt;/code&gt; header arrived, because I had assumed API Gateway would be the one writing it. That assumption held on my own deployment and nowhere else: on a directly exposed container, a tunnel, or a reverse proxy that passes the header through, nothing writes that header except the client, and the proof of concept ran against exactly that, the stock image with no proxy in front of it. Tightening every OAuth endpoint to 5 requests a minute on August 7 (they had run on the SDK's looser defaults until then, &lt;a href="https://github.com/modelcontextprotocol/typescript-sdk/blob/1.30.0/src/server/auth/handlers/register.ts#L62-L70" rel="noopener noreferrer"&gt;20 registrations an hour&lt;/a&gt; for one) changed nothing about it.&lt;/p&gt;

&lt;p&gt;The fix is a trust gate, off by default. Before it, &lt;code&gt;extractClientIp&lt;/code&gt; used the &lt;code&gt;Forwarded&lt;/code&gt; header's first &lt;code&gt;for=&lt;/code&gt; value whenever the header was present, from any peer, on any deployment. Now two settings, &lt;code&gt;TRUST_PROXY_HOPS&lt;/code&gt; and &lt;code&gt;TRUST_FORWARDED_HOPS&lt;/code&gt;, state how many proxy hops a deployment trusts for each header family, and both default to zero. At zero the header is ignored and the bucket keys on the TCP peer, which an attacker can't choose, so the stock image the researcher tested is closed without any configuration. An operator who does put a proxy in front opts in with the real hop count.&lt;/p&gt;

&lt;p&gt;Once trust is on, the parser reads from the end of the chain rather than the start, as many entries in as the deployment says it trusts, because the trailing entries are the proxies' own claims and a client can't write those. The case that makes this necessary is API Gateway itself: it discards a client-sent &lt;code&gt;Forwarded&lt;/code&gt; header but folds a spoofed &lt;code&gt;X-Forwarded-For&lt;/code&gt; into the &lt;code&gt;Forwarded&lt;/code&gt; chain it writes, ahead of the real peer (&lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#auth-oauth-21--defense-in-depth" rel="noopener noreferrer"&gt;the behaviour is documented in ARCHITECTURE.md&lt;/a&gt;), so a first-element read would key the bucket on the spoof even on my own path.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deployment&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TRUST_FORWARDED_HOPS&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;The limiter keys on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stock container, no proxy&lt;/td&gt;
&lt;td&gt;0 (the default)&lt;/td&gt;
&lt;td&gt;The TCP peer; the header is ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare API Gateway&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The last &lt;code&gt;for=&lt;/code&gt; entry, which the gateway wrote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare in front of the gateway&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;The second-to-last entry; the last is Cloudflare's edge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgzc4xc1hnr0d5g5w5o1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgzc4xc1hnr0d5g5w5o1.png" alt="Reading the last element of the X-Forwarded-For chain instead of the first defeats header spoofing" width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The gateway path with trust turned on. The gateway appends its own claim, so the last entry is the one an attacker can't forge. With trust at its default of zero, none of this header is read at all.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I &lt;a href="https://github.com/aliasunder/vault-cortex/security/advisories/GHSA-wm5v-9236-597m" rel="noopener noreferrer"&gt;published the advisory&lt;/a&gt; the same day, rated high severity and crediting the reporter, and shipped the fix as a security release. Five per minute has also proved loose enough for real clients. A complete OAuth flow touches each endpoint at most twice, and the heaviest burst on record, a reconnect firing four registrations, two authorizes and two token exchanges in 40 seconds, passed without a single throttled request. A later change capped the row count as well. Registrations older than a week holding no unexpired refresh token are swept at boot and before each new registration, so the limiter limits how fast rows appear and the sweep limits how long they stay. At 5 a minute, one address can add about 7,000 rows a day, and each of them is gone a week later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Grade F
&lt;/h3&gt;

&lt;p&gt;The last production change came from outside the traffic: this summer I found a Grade F for the server on the first page of Google. A scanner site I'd never heard of had scanned the repo and graded it F, with a score of zero out of a hundred, and 438 vulnerabilities. Here are just four of the false results: import statements from the MCP SDK were flagged as hardcoded credentials (CVSS 9.1), a &lt;code&gt;setTimeout&lt;/code&gt; promise was flagged as an &lt;code&gt;eval()&lt;/code&gt; with external input (9.8), and two parameterized prepared statements were flagged as an exposed API key (8.5). Every other server I sampled in its registry had scored the same zero since late June. I disputed it with the operator, the page came down within a day, and Google eventually dropped it.&lt;/p&gt;

&lt;p&gt;I'm not naming the scanner, because the problem is the genre: a &lt;a href="https://arxiv.org/abs/2607.11086" rel="noopener noreferrer"&gt;July 2026 study&lt;/a&gt; ran 37,288 MCP servers through eight popular scanners and found average precision of 45.5%. Sifting a second report, a C from a different site and again mostly false positives, still surfaced one real gap, which did result in a change: I added comprehensive OAuth audit logging, including 14 event types covering registration, authorization, PKCE outcomes, grants, and revocations. (The repo's own CI runs CodeQL, Socket Security, Gitleaks, Trivy, OpenSSF Scorecard, and Dependabot, and releases are cosign-signed into a public transparency log.)&lt;/p&gt;

&lt;h2&gt;
  
  
  One Scope, Two Spec Items Closed
&lt;/h2&gt;

&lt;p&gt;Reflecting on three months of production use, I'd keep the single scope. The server advertises one, &lt;code&gt;vault&lt;/code&gt;, and no layer checks it, because there's only one level of privilege for a check to decide between. &lt;code&gt;READONLY_MODE&lt;/code&gt; and &lt;code&gt;DISABLED_TOOLS&lt;/code&gt; limit what a token can do at the tool layer, which is the risk that actually exists here. The consent page is the other thing I'd leave as it is: no account to sign in to, nothing for the page to remember, and approving checks only that the authorization request is still pending and the token matches the server's own.&lt;/p&gt;

&lt;p&gt;Two spec items closed late, one from each spec. The audience item is a MUST the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling" rel="noopener noreferrer"&gt;MCP spec&lt;/a&gt; adds on top of OAuth 2.1: a server has to validate that its tokens were issued specifically for it. For three months the access token carried no audience claim, and the issuer claim it did carry was never compared to anything. The per-deployment signing key meant a token only verified on the server that minted it, which is most of what audience binding buys, but two deployments sharing the same secret would have accepted each other's tokens. Tokens now carry an audience naming the server's MCP endpoint and an issuer naming its authorization server, both verifiers require them, and a client that names a different server in its &lt;a href="https://www.rfc-editor.org/rfc/rfc8707" rel="noopener noreferrer"&gt;RFC 8707&lt;/a&gt; resource parameter gets &lt;code&gt;invalid_target&lt;/code&gt; instead of a token. The checks were a few lines in the verifier. The rollout was the harder part: a token minted before the upgrade carries no audience, and rejecting it at the gateway would strand the client, because a gateway deny is the fixed 403 from earlier and clients refresh only on a 401. So the gateway passes pre-upgrade tokens through temporarily, Express answers them with the 401 that triggers a silent refresh, and the gateway will make this contract strict in a later release.&lt;/p&gt;

&lt;p&gt;The other item was the OAuth 2.1 draft's reuse rule, closed by the fourth refresh-token version (above): a rotated refresh token presented a second time now revokes the whole grant. Before that version, a replay got &lt;code&gt;invalid_grant&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Vault Cortex is open source: &lt;a href="https://github.com/aliasunder/vault-cortex" rel="noopener noreferrer"&gt;github.com/aliasunder/vault-cortex&lt;/a&gt;. Quick start is &lt;code&gt;npx vault-cortex@latest init&lt;/code&gt;. This post is the explanation, not the setup guide: everything here except the AWS edge ships in the container by default. If AWS isn't your preferred provider, the &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md" rel="noopener noreferrer"&gt;remote guide&lt;/a&gt; covers any VPS that runs Docker, with a &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#hardening-recommended" rel="noopener noreferrer"&gt;Hardening section&lt;/a&gt; for anything more than the container's own auth. In addition, the &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/deploy/render/README.md" rel="noopener noreferrer"&gt;Render&lt;/a&gt; and &lt;a href="https://github.com/aliasunder/vault-cortex/blob/main/deploy/railway/README.md" rel="noopener noreferrer"&gt;Railway&lt;/a&gt; guides have one-click deploys, and the README has a &lt;a href="https://github.com/aliasunder/vault-cortex#remote-access-from-anywhere" rel="noopener noreferrer"&gt;comparison table for the 4 remote paths&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The README also has a &lt;a href="https://github.com/aliasunder/vault-cortex#community-deployments" rel="noopener noreferrer"&gt;Community deployments&lt;/a&gt; section for templates other people have built, with the first entry being an Azure Container Apps template by &lt;a href="https://github.com/flytzen" rel="noopener noreferrer"&gt;@flytzen&lt;/a&gt;. If you build one for another platform, open a PR and I'll add it.&lt;/p&gt;

&lt;p&gt;This is the second post in a series about building a personal AI memory system. &lt;a href="https://aliasunder.dev/posts/i-gave-claude-access-to-my-entire-second-brain/" rel="noopener noreferrer"&gt;The first&lt;/a&gt; covers what the server is for and how it held up over 15 days of travel. Next up: the search layer, SQLite FTS5 plus local embeddings.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>security</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Gave Claude Access to My Entire Second Brain</title>
      <dc:creator>aliasunder</dc:creator>
      <pubDate>Tue, 07 Jul 2026 17:21:02 +0000</pubDate>
      <link>https://dev.to/aliasunder/i-gave-claude-access-to-my-entire-second-brain-1kef</link>
      <guid>https://dev.to/aliasunder/i-gave-claude-access-to-my-entire-second-brain-1kef</guid>
      <description>&lt;p&gt;Somewhere north of Lyon, our TGV train stopped moving. It was day four of a fifteen-day trip through France and Italy with my mom, the delay kept growing with no explanation, and my laptop was packed inside a suitcase in the luggage rack. I pulled up Claude on my phone and typed: "Our train is delayed."&lt;/p&gt;

&lt;p&gt;It read the day's itinerary from my Obsidian vault, figured out which train we were on, and reminded me that our taxi in Avignon was booked for 17:30, gave me the booking reference and the dispatcher's number so I could push the pickup, and when I wrote back that we'd stopped again and it was looking like another hour, it did the arithmetic, warned me we were about to miss our hotel's 20:00 check-in cutoff, and handed me the front desk's phone number and email so I could give them a heads-up. My vault was sitting on a server in us-east-1, roughly 6,000 kilometres away, and Claude was reading it for me from a train seat in southern France.&lt;/p&gt;

&lt;p&gt;That moment is what this post is about. Vault Cortex is a self-hosted MCP server that gives AI persistent access to your Obsidian vault from anywhere, on any device, without Obsidian running. I've spent over a year trying to give my AI tools a memory that actually works, and this trip was the first time I really had to depend on the result. This post covers what I built, why the current state of AI memory pushed me to build it, and how it held up over 15 days of real use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The problem in 2026 isn't that AI tools lack memory. Almost every assistant ships some form of it now, and there's a whole funded industry racing to add more. The catch is where all that memory lives. &lt;a href="https://support.claude.com/en/articles/11817273-use-claude-s-chat-search-and-memory-to-build-on-previous-context" rel="noopener noreferrer"&gt;Claude's memory&lt;/a&gt; covers the Claude apps but not Claude Code, which keeps its own auto-memory scoped to each project folder on each machine, and the API offers a third mechanism that developers wire up themselves. ChatGPT's memory is a ChatGPT feature. Gemini's personalization doesn't even reach every part of Gemini. Each project I set up gets its own hand-curated context on top of all that. And the dedicated memory startups (&lt;a href="https://techcrunch.com/2025/10/28/mem0-raises-24m-from-yc-peak-xv-and-basis-set-to-build-the-memory-layer-for-ai-apps/" rel="noopener noreferrer"&gt;mem0 raised $24 million&lt;/a&gt; and became the memory provider for AWS's agent SDK, with at least a half-dozen serious frameworks behind it) are building for app developers, storing your memories in vector and graph databases behind an API. Everyone is trying to solve this problem, which tells you how real it is. But every one of these systems stores context in a different place, in a format I don't control, and the most portability any of them offers is a copy-paste export of a synthesized summary.&lt;/p&gt;

&lt;p&gt;So the day-to-day experience is still amnesia. Ask a new chat about a decision you made yesterday in a different tool and you're starting from zero, pasting in context, or maintaining some giant prompt document that drifts out of date. I've come to think the memory shouldn't live inside any one assistant or any one vendor's database. It should live in plain files you own, in one place, with every assistant reading and writing the same store. (Even Anthropic seems to be arriving at the same place: the memory for their &lt;a href="https://claude.com/blog/claude-managed-agents-memory" rel="noopener noreferrer"&gt;newest managed agents&lt;/a&gt; is stored as plain, exportable files.)&lt;/p&gt;

&lt;p&gt;For me that place was already Obsidian. I had the Obsidian app on my phone for the whole trip, so my notes were technically never out of reach, but reading your notes and having an AI that understands your notes are different things. I didn't want to dig through twenty documents to reconstruct a travel day. I wanted to ask "what's the plan for tomorrow?" and get an answer pulled together from the itinerary, the restaurant guide, the booking confirmations, and the activity notes. There are dozens of MCP servers that connect Claude to an Obsidian vault. Some need Obsidian running with a REST API plugin, some read the markdown files directly, a couple offer remote access through network tunnels. Almost all of them still need your machine powered on and reachable. Close the laptop and the connection dies, and switching to your phone means starting over.&lt;/p&gt;

&lt;p&gt;The wider industry has been circling the same conclusion. "Context engineering" overtook "prompt engineering" in mid-2025, and the emerging architecture keeps landing on markdown files plus structured retrieval. Almost every implementation still assumes you're sitting at your desk. I wanted my knowledge base reachable from any device, anywhere, with nothing running at home, so I built that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Vault Cortex is a standalone, self-hosted MCP server for your Obsidian vault. It gives Claude, or any MCP client, structured access to your notes and files, indexed into SQLite, exposed through dedicated tools and guided prompts, and secured with OAuth 2.1. Run locally, it takes about two minutes and involves no cloud at all, just Docker and a folder of markdown files. That alone replaces the usual three-part local chain of Obsidian open, a REST API plugin installed, and a separate MCP server wrapping it. But the real power comes from the remote setup, where a single container on a small VPS runs the server alongside a bundled headless Obsidian Sync client, so the same vault that syncs to my laptop and phone is also readable and writable by Claude from anywhere, including claude.ai on mobile, which can never reach a localhost server.&lt;/p&gt;

&lt;p&gt;A few design choices that make that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reads your files directly.&lt;/strong&gt; Notes are indexed and searchable, and it handles images, PDFs, Canvas files, and data files too. Nothing gets installed in Obsidian, Obsidian never needs to be open, and technically any folder of markdown files works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault management with a full suite of tools.&lt;/strong&gt; Search, read, write, surgical patching, memory files, task queries, properties, tags, links, daily notes, plus guided workflows for vault health, memory review, and daily reconciliation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search combines keywords with vectors.&lt;/strong&gt; Keyword search (SQLite FTS5 with BM25 ranking) fused with semantic vector search and reranked by a cross-encoder, so a natural-language question finds the right note even when the exact words don't match. The embedding models run locally inside the container, so no note content ever leaves your server, and search falls back to keyword-only while the vectors build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication is full OAuth 2.1.&lt;/strong&gt; PKCE, dynamic client registration, refresh token rotation, JWT verification. For a server with write access to your personal notes this is not optional, and the ecosystem is worse at it than you'd hope: &lt;a href="https://www.bluerock.io/use-cases/safely-adopt-mcp" rel="noopener noreferrer"&gt;one 2026 analysis&lt;/a&gt; found only 8.5% of MCP servers implement OAuth and 41% have no authentication at all. The reasoning behind the implementation gets its own post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It connects over streamable HTTP.&lt;/strong&gt; It works as a Claude.ai remote connector or on localhost, with no stdio required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vault stays current automatically.&lt;/strong&gt; In the remote setup, a headless Obsidian Sync client runs inside the same container as the server, so edits from any Obsidian app reach the server in seconds and anything Claude writes flows back to every device. The sync itself is powered by the &lt;a href="https://obsidian.md/help/headless" rel="noopener noreferrer"&gt;obsidian-headless&lt;/a&gt; CLI, and the containerization approach was inspired by &lt;a href="https://github.com/Belphemur/obsidian-headless-sync-docker" rel="noopener noreferrer"&gt;@Belphemur's obsidian-headless-sync-docker&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq34uvm5estw1d4e02o99.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq34uvm5estw1d4e02o99.png" alt="Vault Cortex remote architecture: phone to claude.ai to API Gateway to Vault Cortex on a Lightsail VPS with SQLite index and vault files, kept in sync with your devices by a headless Obsidian Sync client" width="800" height="356"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The remote setup: cyan is the MCP request path, purple is the Obsidian Sync path.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's a real interaction from the trip. I needed a Paris restaurant booking I'd consolidated in my vault, and this is the actual response from the search layer, trimmed for length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;vault_search(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;query:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Semilla restaurant"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Trip Planning/guides/paris-restaurants.md"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Paris — Restaurant Guide"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"snippet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"...Primary: Semilla / Address: 54 Rue de Seine, 75006 /
                  Walk: ~7 min from hotel. Flat. / What to expect:
                  Wine-forward bistronomic restaurant, creative..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One tool call returned the restaurant guide with everything I needed. The trip's Claude project did have my planning docs uploaded as project knowledge, but those copies dated from March and this booking was made weeks later, so project search came up empty. The booking confirmation was in my email too, but the vault had the full restaurant guide, everything I'd pulled together while planning, in one search result.&lt;/p&gt;

&lt;h2&gt;
  
  
  15 Days, 216 Tool Calls
&lt;/h2&gt;

&lt;p&gt;I travelled with my mom for 15 days in May and June 2026, through Paris, Avignon, Verona, and Venice. The vault held everything for the trip: city-by-city itineraries, restaurant guides with booking confirmations, a multi-leg travel day guide, a luggage strategy document, and a running task board. Over those 15 days I had just over 30 Claude chats, and when I audited them after the trip, five out of every six had leaned on the vault. I also went back through the server logs while writing this post, so these are actual counts rather than my memory of the trip: 216 tool calls, 89 note reads, 79 surgical edits, 16 searches, every one of them from my phone. My laptop stayed in the suitcase.&lt;/p&gt;

&lt;p&gt;Most sessions were read-only, pulling up booking references, phone numbers, opening hours, day plans. The writes were small and deliberate. When I moved a lunch reservation in Verona from 13:00 to 14:00, Claude cascaded the change through four places across two guides so nothing went stale. When I noticed the luggage guide described our setup wrong, it made four corrections from my phone on the last travel day. A few moments from the trip stuck with me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 84-call night in Avignon.&lt;/strong&gt; The evening before a four-train, 12.5-hour travel day, I sat in our hotel room sorting out logistics with Claude, and the logs show what that evening turned into: 84 tool calls and 55 vault edits. The travel day guide got its ticket formats corrected (our confirmation emails insisted two trains needed printed A4 tickets, but Claude checked SNCF's own FAQ and confirmed phone barcodes were fine for all four), the departure taxi reference was added, the packing checklist was updated, and the payment tables were reconciled. Not everything went our way that night. I also tried to cancel two redundant train segments and learned I'd missed the refund cutoff by a day, which cost us about CA$190, and the refund estimate in my own planning notes turned out to be optimistic. No memory system saves you from fine print. What it did do was keep every document accurate for the next morning, when a completely different chat session read the same travel guide to answer luggage questions on the way to the station. The edits from the night before were just there, in a new chat, with no effort from me. That's what I actually built this for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the wrong board in Torino.&lt;/strong&gt; During our transfer at Torino Porta Nuova, I photographed a departure board and asked Claude how to interpret it. It explained the Italian board conventions, then pointed out that I'd photographed the arrivals board and needed the departures board instead, and pulled our exact train from the vault so I knew what to look for: Frecciarossa 9753 to Verona Porta Nuova at 17:05, carriage 1, seats 6A and 6B. All of that came from a travel guide written in Obsidian weeks earlier, which beat scrolling through email confirmations in a crowded station with our luggage in tow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A church itinerary card, fixed on the go.&lt;/strong&gt; In Verona, Claude built me a phone-sized HTML itinerary card for a day of church visits, working from the vault's activity guide. I noticed the walking route doubled back on itself, so I asked, and it checked the geography, reordered the stops into one clean north-to-south loop, and adjusted the schedule so we'd be inside a church during a forecast storm window. Then I had it tighten the layout until the whole card fit in two iPhone screenshots, because that's how I used it for the rest of the day, straight from my camera roll.&lt;/p&gt;

&lt;p&gt;The information was never perfect, and I want to be honest about that. I caught a wrong day-of-week label, an incorrect luggage count, and a wrong assumption about when our museum cards activated, and some of those errors were Claude's own. What changed is that corrections became cheap and durable enough that I actually made them, from wherever we happened to be standing. That saved me a lot of sifting through my own detailed planning docs to figure out what was still true.&lt;/p&gt;

&lt;p&gt;One thing I didn't expect: the same system ran my project work too. One evening in Avignon I closed out a work session on the Vault Cortex project itself from my phone, writing the session log, moving task cards, appending to my memory files, and later in the trip I prepped the project's registry submissions from a hotel room in Venice. And a handful of chats never touched the vault at all, because not everything needs it.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/v5Jn_RpAFd0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Vault Cortex in action: a 60-second demo from Claude mobile&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbz4gia5c8rft9u9mo4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbz4gia5c8rft9u9mo4q.png" alt="Left: asking Claude on mobile to save trip lessons, showing the Write Note tool call. Right: the resulting Next Trip Lessons Learned note open in Obsidian" width="800" height="391"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Asking Claude to save trip lessons from my phone, and the note it wrote, synced back to Obsidian.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Vault Cortex is open source: &lt;a href="https://github.com/aliasunder/vault-cortex" rel="noopener noreferrer"&gt;github.com/aliasunder/vault-cortex&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Got Here
&lt;/h2&gt;

&lt;p&gt;Vault Cortex is the fifth iteration on a problem I've been working at for over a year: how do you give an AI assistant a memory it can keep?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenMemory/mem0.&lt;/strong&gt; I started with their open-source memory layer. It advertised support for non-OpenAI providers, and in practice that support was broken, so I forked it, fixed the environment variable bugs, patched its categorization to work with providers beyond OpenAI, and fought Qdrant crash-loops on macOS. After months of patching someone else's codebase, the recalls were still shallow and the categorization still unreliable, and saving was so slow that calls regularly looked like they'd failed when they hadn't. I ended up keeping a standing instruction for my agents: a failure doesn't mean the memory wasn't saved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NeuralComposer and LightRAG.&lt;/strong&gt; It started as an Obsidian plugin, so Obsidian had to be running for any of it to work. The graph RAG retrieval was genuinely good when it worked, but in daily use it was slow and finicky, calls failed often, and the operational weight was unsustainable: 385 MB of working data, three forked repos, and a launchd daemon I built to decouple it from Obsidian just to keep it all running."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The obsidian-vault plugin.&lt;/strong&gt; If Claude was going to write into my vault, it needed to understand frontmatter, wikilinks, callouts, Kanban boards, and a dozen community plugins, so I codified those conventions into an AI-readable skill. It became a standalone open-source plugin that I still use in every session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLAUDE.md protocols.&lt;/strong&gt; I built a three-layer memory architecture (semantic for who I am, episodic for what happened, working for what's current) with session start and end protocols, so every conversation picks up where the last one left off. This is the layer that makes Claude feel like it actually knows me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault Cortex.&lt;/strong&gt; The piece that made all of it reachable from anywhere, on any device, in any client.&lt;/p&gt;

&lt;p&gt;I tried the off-the-shelf solution, forked it when it broke, and after months of patching, the output still wasn't good enough, so I built my own. Each of these stages gets its own deep dive later in the series.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmhsqngqyd0y3y9bgys3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmhsqngqyd0y3y9bgys3.png" alt="Timeline of five iterations on AI memory from June 2025 to May 2026: OpenMemory/mem0, NeuralComposer plus LightRAG, the obsidian-vault plugin, CLAUDE.md protocols, and Vault Cortex" width="799" height="233"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Vault Cortex is open source, self-hostable, and works today.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/aliasunder/vault-cortex" rel="noopener noreferrer"&gt;github.com/aliasunder/vault-cortex&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quick start:&lt;/strong&gt; &lt;code&gt;npx vault-cortex@latest init&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you need:&lt;/strong&gt; an Obsidian vault (or any folder of markdown files) and Docker. For remote access, add a small VPS and an Obsidian Sync subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; my remote setup runs about $30 a month, made up of a $24 Lightsail instance (2 vCPU / 4 GB, which gives comfortable headroom for the local embedding models and concurrent search), under a dollar of API Gateway, and $5 for Obsidian Sync. A smaller instance (1 vCPU / 2 GB, $12/mo) handles semantic search fine for a typical vault, bringing the total closer to $18. If you skip semantic search entirely you can go smaller still, and a local-only setup is free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture decisions in Vault Cortex are mine, and the implementation happened in conversation with Claude across hundreds of sessions over several months. I think that's worth being transparent about. Working with AI well is an engineering skill in its own right, and this whole project exists to make that collaboration work better.&lt;/p&gt;

&lt;p&gt;If this sounds like a problem you have, the repo is at &lt;a href="https://github.com/aliasunder/vault-cortex" rel="noopener noreferrer"&gt;github.com/aliasunder/vault-cortex&lt;/a&gt; and stars genuinely help a new project get found. The next post in the series covers the OAuth 2.1 implementation, and later ones dig into the search layer and the memory architecture that makes Claude feel like it actually knows me.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>obsidian</category>
      <category>mcp</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
