Vault Cortex 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.
One 2026 analysis 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.
The Threat Model
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 .obsidian/ 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.
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 execute-api 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.
Reference (AWS): client → Cloudflare (proxied domain, WAF)
→ API Gateway (Lambda authorizer)
→ Cloudflare Tunnel (Access-locked)
→ container (Express)
One-click: client → container (Express)
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.
Two Credentials, Two Kinds of Client
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 exists in beta 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.)
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.
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.
What the MCP Spec Requires
Authorization is optional in the MCP specification, so a server with no authentication at all still conforms. When a server does authenticate over HTTP, the MCP spec's answer is OAuth 2.1 (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 ARCHITECTURE.md, it's nine steps:
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?...&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 <JWT>) → real requests
9. POST /token (refresh_token) on expiry → new JWT, no browser
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 /register 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 Client ID Metadata Documents 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.

The flow at a glance. The only human step is the consent click.
/authorize 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 redirect_uri has to match exactly at authorization time (the SDK relaxes only the port, for RFC 8252 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.

The consent page. The token field is the gate: approving proves you hold the server's secret.
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 requireBearerAuth 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.
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: sub, scope, exp, iss, aud, and iat.
Verifying Twice
In the reference deployment, every /mcp 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 requireBearerAuth. 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.
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.
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 /healthz, because docker-compose healthchecks don't carry a token.
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.
| Layer | Rejected | What they were |
|---|---|---|
| API Gateway | about 1,160 | 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 |
| Lambda authorizer | 0 of 23,619 | Every invocation that reached it carried a valid token (a tokenless request gets the gateway's 401 and never invokes it) |
| Express | 2 | Bad-token probes I sent over Tailscale, behind the gateway |

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.
The 30-Line Verifier
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 alg: none) can't happen here: the verifier accepts HS256 only, computes the expected signature directly, and compares. There's no alg 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 crypto.createHmac (OpenSSL underneath) over three base64url segments and crypto.timingSafeEqual to compare them.
My practical reasoning was the Lambda bundle. The authorizer imports verifyJwt, 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:
const sigBuf = Buffer.from(sig, "base64url")
const expBuf = Buffer.from(expected, "base64url")
if (sigBuf.length !== expBuf.length) return null
if (!timingSafeEqual(sigBuf, expBuf)) return null
timingSafeEqual 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.
Refresh Tokens: Four Versions
Refresh tokens are where the design moved the most: four versions since launch.
| Version | When | What changed | What it cost existing clients |
|---|---|---|---|
| 1 | Launch, May | Rotated on every use, no expiry | Nothing; re-auth only if the data volume was wiped |
| 2 | Three days after launch | 60-day sliding inactivity window; expired rows delete on read | One re-auth for every active session |
| 3 | August | Rows keyed by HMAC-SHA256(secret, token), bound to the registering client; rotating the secret orphans every row |
One re-auth per client, all within 24 hours |
| 4 | August | Reuse of a rotated refresh token revokes the whole grant: the client's refresh token and its access tokens | Nothing, unless a token is replayed |
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 expires_at INTEGER NOT NULL DEFAULT 0, 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.
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 HMAC-SHA256(secret, token), so the database never holds a token anyone could present. Refresh tokens are also bound to the client that registered them, which the OAuth 2.1 draft 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 invalid_grant, the old access token gets a 401, and a fresh consent succeeds.
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 openssl rand -hex 32), which puts brute force out of reach.
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 OAuth 2.1 draft has the server revoke the whole grant either way. Until this version, a replay got invalid_grant 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.
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.
What Production Changed
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 spec names 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.
The 403 claude.ai Reported as a Connection Issue
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 --connect-to (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.
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 no Gateway Responses to reshape it. The documented ways 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 Unauthorized error. Claude Code accepts either status. claude.ai requires the 401, and its connector docs say so 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 /mcp, and the gateway turned each deny into a 403. It went unnoticed for a month because established connections never broke: refresh uses the open /token route, so only a first tokenless request (a new connector, a fresh install) ever saw it.
The fix was the identity source: registering the Authorization header as the authorizer's identity source and splitting the routes, so tokenless requests to /mcp 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.
The 404s That Were a Spec Gap
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 /.well-known/oauth-protected-resource/mcp, the path-suffixed discovery URL that RFC 9728 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.
Rate Limiting Behind a Proxy
In August a security researcher reported, through GitHub's private vulnerability reporting, that the rate limiter keyed its bucket on the client-supplied Forwarded header without checking that the header came from a trusted proxy. An attacker could open a fresh bucket per request, and since /register 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 clients table: one from a pre-test check, five from the control run, and six from the spoof.
The bypass was older than the limit. Since launch day in May, the bucket key had trusted whatever Forwarded 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, 20 registrations an hour for one) changed nothing about it.
The fix is a trust gate, off by default. Before it, extractClientIp used the Forwarded header's first for= value whenever the header was present, from any peer, on any deployment. Now two settings, TRUST_PROXY_HOPS and TRUST_FORWARDED_HOPS, 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.
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 Forwarded header but folds a spoofed X-Forwarded-For into the Forwarded chain it writes, ahead of the real peer (the behaviour is documented in ARCHITECTURE.md), so a first-element read would key the bucket on the spoof even on my own path.
| Deployment | TRUST_FORWARDED_HOPS |
The limiter keys on |
|---|---|---|
| Stock container, no proxy | 0 (the default) | The TCP peer; the header is ignored |
| Bare API Gateway | 1 | The last for= entry, which the gateway wrote |
| Cloudflare in front of the gateway | 2 | The second-to-last entry; the last is Cloudflare's edge |

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.
I published the advisory 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.
The Grade F
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 setTimeout promise was flagged as an eval() 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.
I'm not naming the scanner, because the problem is the genre: a July 2026 study 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.)
One Scope, Two Spec Items Closed
Reflecting on three months of production use, I'd keep the single scope. The server advertises one, vault, and no layer checks it, because there's only one level of privilege for a check to decide between. READONLY_MODE and DISABLED_TOOLS 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.
Two spec items closed late, one from each spec. The audience item is a MUST the MCP spec 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 RFC 8707 resource parameter gets invalid_target 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.
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 invalid_grant and nothing else.
Try It
Vault Cortex is open source: github.com/aliasunder/vault-cortex. Quick start is npx vault-cortex@latest init. 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 remote guide covers any VPS that runs Docker, with a Hardening section for anything more than the container's own auth. In addition, the Render and Railway guides have one-click deploys, and the README has a comparison table for the 4 remote paths.
The README also has a Community deployments section for templates other people have built, with the first entry being an Azure Container Apps template by @flytzen. If you build one for another platform, open a PR and I'll add it.
This is the second post in a series about building a personal AI memory system. The first 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.
Top comments (0)