惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy International News Feed
I
Intezer
T
Tenable Blog
S
Schneier on Security
Project Zero
Project Zero
G
GRAHAM CLULEY
酷 壳 – CoolShell
酷 壳 – CoolShell
小众软件
小众软件
Know Your Adversary
Know Your Adversary
博客园 - 司徒正美
The Cloudflare Blog
Recent Commits to openclaw:main
Recent Commits to openclaw:main
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
N
News and Events Feed by Topic
博客园 - 叶小钗
宝玉的分享
宝玉的分享
L
LINUX DO - 热门话题
aimingoo的专栏
aimingoo的专栏
S
Secure Thoughts
Forbes - Security
Forbes - Security
T
The Exploit Database - CXSecurity.com
D
Darknet – Hacking Tools, Hacker News & Cyber Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 【当耐特】
罗磊的独立博客
IT之家
IT之家
H
Hacker News: Front Page
I
InfoQ
云风的 BLOG
云风的 BLOG
S
Security Affairs
M
MIT News - Artificial intelligence
GbyAI
GbyAI
Jina AI
Jina AI
Help Net Security
Help Net Security
Engineering at Meta
Engineering at Meta
大猫的无限游戏
大猫的无限游戏
Webroot Blog
Webroot Blog
L
Lohrmann on Cybersecurity
A
About on SuperTechFans
Attack and Defense Labs
Attack and Defense Labs
The Register - Security
The Register - Security
V
V2EX
G
Google Developers Blog
D
DataBreaches.Net
Apple Machine Learning Research
Apple Machine Learning Research
C
Cybersecurity and Infrastructure Security Agency CISA
J
Java Code Geeks
W
WeLiveSecurity
Cloudbric
Cloudbric
T
Tor Project blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
MCP Auth That Actually Works: OAuth for Remote Servers
Eugen · 2026-04-24 · via DEV Community

In the first post I showed what an AI does with 118 MCP tools. In the second I showed how we organized them. Both posts assumed a working connection. This one covers the hardest part: getting that connection to work securely.

Most MCP servers run locally. You add a JSON block to your Claude config, paste an API key, and you're done. That works for developer tools. It doesn't work for a SaaS product where users have teams, roles, and data they share with other people.

We needed something different: a remote MCP server where users log in through their browser, pick a team, choose what the AI can access, and revoke it whenever they want. No API keys. No config files on disk.

The problem with local MCP servers

The standard MCP setup looks like this:

{
  "mcpServers": {
    "my-tool": {
      "command": "npx",
      "args": ["my-mcp-server"],
      "env": {
        "API_KEY": "sk-live-abc123"
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

This has three problems for a SaaS product:

1. API keys are static secrets. If a key leaks, it has full access until someone manually rotates it. There's no expiry, no scope, no revocation without generating a new key.

2. No user consent flow. The user pastes a key and hopes for the best. There's no screen that says "this AI assistant wants to read your invoices and create transactions - approve?"

3. No multi-tenancy. If your product has teams, which team does the API key belong to? If a user is on three teams, do they need three keys? How do they switch?

A remote MCP server with OAuth solves all three. The user authenticates through a browser, picks a team, grants specific permissions, and gets a token that expires in an hour. The AI never sees a password. The user can disconnect from the app's settings page.

What we built

Our MCP server runs at https://mcp.paperlink.online/api/mcp/mcp and uses streamable HTTP transport - no local process, no stdio, no binary to install. The connection command is:

claude mcp add --transport http paperlink https://mcp.paperlink.online/api/mcp/mcp

Enter fullscreen mode Exit fullscreen mode

When a client connects for the first time, it discovers the OAuth endpoints through two well-known documents. Then it runs a standard OAuth 2.1 authorization code flow with PKCE. The user sees a consent screen in their browser. After approval, the client gets a short-lived access token and a 30-day refresh token.

Here's the full flow:

Client                         Server                         User's Browser
  |                              |                                  |
  |-- GET /.well-known/          |                                  |
  |   oauth-protected-resource ->|                                  |
  |<- {auth_server, scopes}      |                                  |
  |                              |                                  |
  |-- GET /.well-known/          |                                  |
  |   oauth-authorization-server |                                  |
  |<- {authorize, token, revoke} |                                  |
  |                              |                                  |
  |-- Open browser: /authorize?  |                                  |
  |   code_challenge=...&        |                                  |
  |   scope=invoices:read ------>|-- Redirect to consent UI ------->|
  |                              |                                  |
  |                              |          User picks team,        |
  |                              |          reviews scopes,         |
  |                              |          clicks Approve          |
  |                              |                                  |
  |                              |<- POST consent (teamId, scopes) -|
  |<- Redirect: ?code=abc123    -|                                  |
  |                              |                                  |
  |-- POST /token               -|                                  |
  |   code=abc123&               |                                  |
  |   code_verifier=xyz          |                                  |
  |<- {access_token, refresh}    |                                  |
  |                              |                                  |
  |-- POST /api/mcp/mcp         -|                                  |
  |   Authorization: Bearer ...  |                                  |
  |<- Tool results               |                                  |

Enter fullscreen mode Exit fullscreen mode

Let me walk through each piece.

Step 1: Discovery

MCP clients discover auth endpoints through two standard documents. The protected resource metadata tells the client where to authenticate:

GET https://mcp.paperlink.online/.well-known/oauth-protected-resource

Enter fullscreen mode Exit fullscreen mode

{
  "resource": "https://mcp.paperlink.online/api/mcp/mcp",
  "authorization_servers": ["https://app.paperlink.online"],
  "scopes_supported": [
    "invoices:read", "accounting:read", "accounting:write", "accounting:delete",
    "companies:read", "companies:write", "clients:read", "clients:write",
    "products:read", "products:write", "companies:delete", "clients:delete",
    "products:delete", "invoices:write", "invoices:delete",
    "estimates:read", "estimates:write", "estimates:delete",
    "teams:read", "teams:write", "billing:read",
    "ai:read", "ai:write", "sharing:read", "sharing:write"
  ]
}

Enter fullscreen mode Exit fullscreen mode

Then the authorization server metadata tells the client the exact endpoints:

GET https://app.paperlink.online/.well-known/oauth-authorization-server

Enter fullscreen mode Exit fullscreen mode

{
  "issuer": "https://app.paperlink.online",
  "authorization_endpoint": "https://app.paperlink.online/api/oauth/authorize",
  "token_endpoint": "https://app.paperlink.online/api/oauth/token",
  "revocation_endpoint": "https://app.paperlink.online/api/oauth/revoke",
  "registration_endpoint": "https://app.paperlink.online/api/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["invoices:read", "..."]
}

Enter fullscreen mode Exit fullscreen mode

Notice code_challenge_methods_supported: ["S256"]. We only support S256, not plain. This is intentional - plain PKCE offers almost no security benefit over no PKCE at all.

Also notice token_endpoint_auth_methods_supported: ["none"]. MCP clients are public clients (no client secret), so authentication happens entirely through PKCE.

Step 2: Authorization

The client opens the user's browser to the authorization endpoint:

GET /api/oauth/authorize?
  response_type=code&
  client_id=claude-desktop&
  redirect_uri=http://localhost:5555/callback&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256&
  scope=invoices:read+accounting:write+sharing:read&
  state=random-csrf-token

Enter fullscreen mode Exit fullscreen mode

The server validates the request and redirects to the consent UI. Two things get validated before the user sees anything:

// Only S256 - reject plain PKCE
if (codeChallengeMethod !== 'S256') {
  return NextResponse.json({
    error: 'invalid_request',
    error_description: 'Only code_challenge_method=S256 is supported',
  }, { status: 400 });
}

// Redirect URI must match known patterns
if (!isAllowedRedirectUri(clientId, redirectUri)) {
  return NextResponse.json({
    error: 'invalid_request',
    error_description: 'redirect_uri is not registered for this client',
  }, { status: 400 });
}

Enter fullscreen mode Exit fullscreen mode

The redirect URI validation is worth expanding on. MCP clients use localhost callbacks (Claude Desktop uses http://localhost:PORT/callback), and browser-based clients like Claude.ai and ChatGPT use their own domains. We maintain an allowlist:

const SAFE_REDIRECT_PATTERNS = [
  'http://127.0.0.1:*/*',        // Desktop clients (IP)
  'http://localhost:*/*',         // Desktop clients (hostname)
  'https://*.claude.ai/*',       // Claude.ai
  'https://claude.ai/*',
  'https://*.chatgpt.com/*',     // ChatGPT
  'https://chatgpt.com/*',
  'https://*.openai.com/*',      // OpenAI
  'https://*.perplexity.ai/*',   // Perplexity
  'https://*.mistral.ai/*',      // Mistral
  'https://*.vscode.dev/*',      // VS Code
  // ... full list in mcpClientRegistry.ts
];

Enter fullscreen mode Exit fullscreen mode

The patterns use glob-like matching: * in a port position matches any port, *.claude.ai matches any subdomain. The matcher parses the URI and compares scheme, host, port, and path separately.

Any client_id gets through as long as the redirect URI matches a known pattern. This is dynamic client registration - we don't pre-register clients. Security comes from PKCE plus redirect URI validation, not from client secrets.

Step 3: The consent screen

The consent screen is a regular Next.js page. The user must already be logged in (NextAuth session required). They see two things: a team picker and a scope list.

export function OAuthConsentForm({
  teams,
  requestedScopes,
  clientId,
  redirectUri,
  state,
  codeChallenge,
  codeChallengeMethod,
  dict,
}: OAuthConsentFormProps) {
  const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
    teams[0]?.id
  );

  return (
    <form action={formAction}>
      {/* Team selector - one connection = one team */}
      <Dropdown
        options={teams.map(t => ({ value: t.id, label: t.name }))}
        value={selectedTeamId}
        onChange={setSelectedTeamId}
        label={dict.consent.selectTeam}
      />

      {/* Scope display - user sees what they're granting */}
      <ul>
        {requestedScopes.map(scope => (
          <li key={scope}>
            <span className="text-success"></span>
            {dict.consent.scopes[scope] ?? scope}
          </li>
        ))}
      </ul>

      <button type="button" onClick={handleDeny}>Deny</button>
      <button type="submit">Approve</button>
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

The team picker is the key piece. PaperLink is multi-tenant - a user might belong to "My Freelance Business" and "Acme Corp." Each MCP connection is scoped to exactly one team. If you want AI access to both teams, you create two connections. This is intentional: you might want Claude to have full access to your personal team but read-only access to the company team.

The scopes are displayed with human-readable labels from the i18n dictionary. invoices:read shows as "View invoices and estimates." accounting:write shows as "Create and modify transactions." Users understand what they're granting.

Step 4: Authorization code

When the user clicks Approve, a server action creates an authorization code:

async execute(input: CreateAuthorizationCodeInput): Promise<Result<{ code: string }>> {
  // Verify user is an active member of the selected team
  const member = await this.teamMemberRepository.findByTeamAndUser(
    input.teamId,
    input.userId
  );
  if (!member?.isActive()) {
    return Result.Forbidden(McpPermissionErrors.teamAccessDenied);
  }

  // 256-bit code (two UUIDs concatenated)
  const code = crypto.randomUUID() + crypto.randomUUID();

  // Only valid scopes pass through - invalid ones are silently dropped
  const validScopes = input.scopes.filter((s): s is McpScope =>
    Object.values(McpScope).includes(s as McpScope)
  );

  const entity = McpAuthorizationCodeEntity.create({
    code,
    userId: input.userId,
    teamId: input.teamId,
    teamRole: member.getRole(),   // OWNER, ADMIN, MANAGER, MEMBER
    codeChallenge: input.codeChallenge,
    codeChallengeMethod: input.codeChallengeMethod,
    scopes: validScopes,
    // expiresAt: now + 10 minutes (set automatically)
  });

  await this.mcpAuthorizationCodeRepository.save(entity);

  return Result.Success({ code });
}

Enter fullscreen mode Exit fullscreen mode

The authorization code is a domain entity, not a raw database record. McpAuthorizationCodeEntity.create() validates all inputs and sets the 10-minute expiry automatically. The PKCE challenge is stored alongside the code for verification in the next step.

Two security details worth noting:

Invalid scopes are silently dropped, not rejected. If a client requests invoices:read admin:superpower, the code is created with only invoices:read. This prevents a misbehaving client from blocking the entire flow over an unrecognized scope.

Team membership is verified at code creation time, not just at consent display. A race condition where a user is removed from a team between seeing the consent screen and clicking Approve is handled here.

Step 5: Token exchange

The client exchanges the authorization code for tokens:

POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=<256-bit-code>&
code_verifier=<PKCE-verifier>&
redirect_uri=http://localhost:5555/callback&
client_id=claude-desktop

Enter fullscreen mode Exit fullscreen mode

The exchange use case performs five validations:

async execute(input: ExchangeCodeForTokenInput): Promise<Result<TokenResponse>> {
  // 1. Find and DELETE the code in one operation (one-time use)
  const authCode = await this.mcpAuthorizationCodeRepository
    .findByCodeAndDelete(input.code);
  if (!authCode) return Result.Error('invalid_grant');

  // 2. Check the 10-minute window
  if (authCode.isExpired()) return Result.Error('invalid_grant');

  // 3. PKCE verification (timing-safe comparison)
  const pkceValid = await authCode.verifyCodeChallenge(input.codeVerifier);
  if (!pkceValid) return Result.Error('invalid_grant');

  // 4. Redirect URI must match exactly
  if (authCode.getRedirectUri() !== input.redirectUri) {
    return Result.Error('invalid_grant');
  }

  // 5. Client ID must match
  if (authCode.getClientId() !== input.clientId) {
    return Result.Error('invalid_grant');
  }

  // All checks passed - generate tokens
  const accessToken = generateSecureToken();   // 256-bit random
  const refreshToken = generateSecureToken();

  // Hash before storing - raw tokens never touch the database
  const [tokenHash, refreshTokenHash] = await Promise.all([
    sha256Hash(accessToken),
    sha256Hash(refreshToken),
  ]);

  const now = Date.now();
  const tokenEntity = McpAccessTokenEntity.create({
    userId: authCode.getUserId(),
    teamId: authCode.getTeamId(),
    teamRole: authCode.getTeamRole(),
    tokenHash,
    refreshTokenHash,
    scopes: authCode.getScopes(),
    clientName: authCode.getClientId(),
    expiresAt: new Date(now + ACCESS_TOKEN_TTL_MS),         // 1 hour
    refreshExpiresAt: new Date(now + REFRESH_TOKEN_TTL_MS), // 30 days
  });

  await this.mcpAccessTokenRepository.save(tokenEntity);

  return Result.Success({
    accessToken,       // Raw value - only time it exists
    refreshToken,
    expiresIn: ACCESS_TOKEN_EXPIRES_IN_SECONDS,
    tokenType: 'Bearer',
  });
}

Enter fullscreen mode Exit fullscreen mode

The route handler converts camelCase to the OAuth-standard snake_case response:

const token = result.value!;
return NextResponse.json({
  access_token: token.accessToken,
  token_type: token.tokenType,
  expires_in: token.expiresIn,
  refresh_token: token.refreshToken,
});

Enter fullscreen mode Exit fullscreen mode

The critical security choice here: tokens are hashed before storage. The database holds SHA-256(token), never the raw token. If someone dumps the database, they get hashes that can't be reversed into working tokens. The raw token is returned to the client exactly once and never stored on the server side.

This is the same pattern GitHub uses for personal access tokens. It's more secure than JWTs for this use case because tokens can be individually revoked without maintaining a blocklist. Find the hash in the database, delete it, done.

Step 6: Token verification on every request

Every MCP request hits the verification flow:

async function handleMcpRequest(request: Request): Promise<Response> {
  const authInfo = await verifyBearerToken(request);

  if (!authInfo) {
    return withMcpCors(
      new Response(
        JSON.stringify({
          error: 'invalid_token',
          error_description: 'No authorization provided',
        }),
        {
          status: 401,
          headers: {
            'Content-Type': 'application/json',
            'WWW-Authenticate':
              'Bearer resource_metadata="https://mcp.paperlink.online/.well-known/oauth-protected-resource"',
          },
        }
      )
    );
  }

  const server = createMcpServer(authInfo);
  const transport = new WebStandardStreamableHTTPServerTransport({});
  await server.connect(transport);
  return withMcpCors(await transport.handleRequest(request));
}

Enter fullscreen mode Exit fullscreen mode

The verifyBearerToken function does three checks:

async execute(bearerToken: string): Promise<McpAuthInfoDto | null> {
  // 1. Hash the incoming token and look it up
  const tokenHash = await sha256Hash(bearerToken);
  const token = await this.mcpAccessTokenRepository.findByTokenHash(tokenHash);
  if (!token) return null;

  // 2. Check expiry and revocation
  if (token.isExpired() || token.isRevoked()) return null;

  // 3. Live membership check - is user still active in this team?
  const member = await this.teamMemberRepository.findByTeamAndUser(
    token.getTeamId(),
    token.getUserId()
  );
  if (!member?.isActive()) return null;

  return {
    userId: token.getUserId(),
    teamId: token.getTeamId(),
    teamRole: member.getRole(),
    scopes: token.getScopes(),
  };
}

Enter fullscreen mode Exit fullscreen mode

Check #3 is the one most OAuth implementations skip. If a team admin removes a user, their MCP token should stop working immediately - not in an hour when the token expires. We check team membership on every request. It's one extra database query, and it means "remove from team" actually removes access.

The returned McpAuthInfoDto is a lightweight object that flows into every tool handler:

interface McpAuthInfoDto {
  userId: string;
  teamId: string;
  teamRole: string;      // OWNER, ADMIN, MANAGER, MEMBER
  scopes: McpScope[];    // [McpScope.INVOICES_READ, McpScope.ACCOUNTING_WRITE]
}

Enter fullscreen mode Exit fullscreen mode

No password, no token, no hash - just the identity and permissions the tool needs.

Step 7: Scope enforcement in tools

Every tool checks its required scope before executing:

export const registerAccountingReadTools: ToolRegistrar = (server, authInfo) => {
  server.registerTool(
    'list-invoices',
    {
      title: 'List Invoices',
      description: 'List invoices for the authenticated team.',
      inputSchema: z.object({
        status: z.string().optional(),
        clientName: z.string().optional(),
        limit: z.coerce.number().int().min(1).max(100).optional(),
        offset: z.coerce.number().int().min(0).optional(),
      }),
      annotations: { readOnlyHint: true },
    },
    async (params) => {
      if (!authInfo.scopes.includes('invoices:read')) {
        return {
          content: [{ type: 'text', text: 'Insufficient scope - invoices:read required.' }],
          isError: true,
        };
      }

      const useCase = mcpUseCases.getListInvoicesViaMcpUseCase();
      const result = await useCase.execute({
        teamId: authInfo.teamId,  // Always from auth, never from params
        status: params.status,
        clientName: params.clientName,
        limit: params.limit,
        offset: params.offset,
      });

      if (!result.isSuccess) {
        return {
          content: [{ type: 'text', text: result.errors.join(', ') }],
          isError: true,
        };
      }

      return {
        content: [
          { type: 'text', text: `Found ${result.value.length} invoices.` },
          { type: 'text', text: JSON.stringify(result.value, null, 2) },
        ],
      };
    }
  );
};

Enter fullscreen mode Exit fullscreen mode

Two things never come from the AI's parameters: teamId and userId. They always come from authInfo, which is populated from the verified token. Even if the AI sends teamId: "someone-elses-team" in the tool call, it's ignored. The auth context wins.

This is the same principle as "don't trust the client" in web apps, applied to AI clients. The AI is a client. It sends parameters. You validate them, but identity always comes from the token.

Token refresh

Access tokens expire in one hour. The client refreshes without user interaction:

async execute(input: { refreshToken: string }): Promise<Result<TokenResponse>> {
  const refreshTokenHash = await sha256Hash(input.refreshToken);
  const existingToken =
    await this.mcpAccessTokenRepository.findByRefreshTokenHash(refreshTokenHash);

  if (!existingToken) return Result.Error('invalid_grant');

  if (existingToken.isRefreshExpired() || existingToken.isRevoked()) {
    return Result.Error('invalid_grant');
  }

  // Issue new pair with same scopes
  const newAccessToken = generateSecureToken();
  const newRefreshToken = generateSecureToken();

  const [newTokenHash, newRefreshTokenHash] = await Promise.all([
    sha256Hash(newAccessToken),
    sha256Hash(newRefreshToken),
  ]);

  const now = Date.now();
  const newTokenEntity = McpAccessTokenEntity.create({
    userId: existingToken.getUserId(),
    teamId: existingToken.getTeamId(),
    teamRole: existingToken.getTeamRole(),
    tokenHash: newTokenHash,
    refreshTokenHash: newRefreshTokenHash,
    scopes: existingToken.getScopes(),
    clientName: existingToken.getClientName(),
    expiresAt: new Date(now + ACCESS_TOKEN_TTL_MS),
    refreshExpiresAt: new Date(now + REFRESH_TOKEN_TTL_MS),
  });

  await this.mcpAccessTokenRepository.save(newTokenEntity);

  // Revoke old token pair (rotation - prevents replay)
  await this.mcpAccessTokenRepository.revoke(existingToken.revoke());

  return Result.Success({
    accessToken: newAccessToken,
    refreshToken: newRefreshToken,
    expiresIn: ACCESS_TOKEN_EXPIRES_IN_SECONDS,
    tokenType: 'Bearer',
  });
}

Enter fullscreen mode Exit fullscreen mode

We use refresh token rotation: every refresh issues a new refresh token and revokes the old one. If a refresh token is used twice, the second attempt fails - which signals a possible token theft.

The refresh token lives for 30 days. After that, the user re-authenticates through the browser. This is the only time they see the consent screen again.

The scope model

We have 25 scopes organized by domain and permission level:

invoices:read    invoices:write    invoices:delete
accounting:read  accounting:write  accounting:delete
companies:read   companies:write   companies:delete
clients:read     clients:write     clients:delete
products:read    products:write    products:delete
estimates:read   estimates:write   estimates:delete
sharing:read     sharing:write
teams:read       teams:write
billing:read
ai:read          ai:write

Enter fullscreen mode Exit fullscreen mode

The pattern is {domain}:{level}. Three levels: read, write, delete. Some domains don't have all three - billing is read-only because there's no "create a subscription" tool.

This maps directly to our file organization from post 2. accountingReadTools.ts checks accounting:read. accountingWriteTools.ts checks accounting:write. The file name tells you the scope.

A typical connection grants 5-10 scopes. A freelancer connecting their personal AI assistant might grant everything. A company admin connecting a shared AI might grant invoices:read and accounting:read only - the AI can look at data but can't change anything.

Why remote is better than local for SaaS

Local (stdio + API key) Remote (HTTP + OAuth)
Setup Edit JSON config file Run one command
Security Static key with full access Scoped token, 1-hour expiry
Multi-tenancy Manual key-per-team Team picker in consent UI
Revocation Generate new key, update config Click "disconnect" in app settings
User consent None - paste key and hope Browser-based scope approval
Cross-device Config file per device Token syncs across Claude.ai devices
Updates Reinstall/rebuild server binary Server updates without client changes
Dependencies Node.js + npm + binary None (HTTP transport)

The last row matters more than you'd think. Local MCP servers require the user to have Node.js installed, run npx, and keep the process alive. Our remote server is just an HTTP endpoint. Claude Desktop, Claude.ai, ChatGPT, Cursor - they all connect with a URL. No local runtime.

Security model summary

What the AI can do:

  • Call any tool that's within the granted scopes
  • Read, create, update, or delete data within the selected team
  • Chain multiple tools in a single conversation

What the AI cannot do:

  • Access data from a team the user didn't select
  • Exceed the granted scopes (read-only connection stays read-only)
  • Impersonate another user (userId always comes from the token)
  • Use an expired or revoked token
  • Access data after the user is removed from the team (live membership check)

What the user controls:

  • Which team to grant access to
  • Which scopes to approve
  • When to disconnect (revoke from app settings)

If you're building a remote MCP server

Here's what we'd recommend based on what worked and what didn't:

Start with PKCE from day one. We briefly considered simple API keys as an MVP. Glad we didn't - retrofitting auth into a working server is painful, and users who connected with API keys would need to re-authenticate anyway.

Hash tokens before storing. This is a one-time cost (two lines of code) that eliminates an entire class of data breach scenarios.

Check team membership on every request. The extra database query is worth it. "Remove user from team" should mean "remove access immediately," not "remove access when their token expires."

Use domain:level scope naming. It scales. When we added the estimates domain, we added estimates:read, estimates:write, estimates:delete and it was obvious where each scope goes.

Return two content blocks. A human-readable summary and the full JSON data. The AI shows the summary to the user and uses the JSON for follow-up operations.

Try it

claude mcp add --transport http paperlink https://mcp.paperlink.online/api/mcp/mcp

Enter fullscreen mode Exit fullscreen mode

You'll see the consent screen, pick a team, and be connected in about 10 seconds. 118 tools, scoped to exactly what you approved.