Testing MCP Server Authentication and Authorization: OAuth, Tokens, and Permission Scoping

Testing MCP Server Authentication and Authorization: OAuth, Tokens, and Permission Scoping

Most MCP server tutorials skip authentication. The examples use stdio transport with no auth, the server runs locally, and there's nothing to secure.

But if you're building an MCP server that exposes real capabilities — file access, database queries, API calls — and if that server is accessible over HTTP, authentication is mandatory. And like all security-critical code, auth needs tests specifically designed to break it.

Here's how to test MCP server authentication and authorization systematically.

What Auth Testing Covers

MCP server auth testing breaks into three areas:

  1. Authentication — does the server correctly verify identity? (Bearer tokens, OAuth, API keys)
  2. Authorization — does the server enforce who can call which tools?
  3. Token lifecycle — does the server handle expired, revoked, and malformed tokens correctly?

All three need tests. Skipping any one of them creates security holes that are hard to find in production.

Testing Bearer Token Authentication

The simplest MCP auth model is a static bearer token. Test both the happy path and every failure mode.

import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';

const BASE_URL = 'http://localhost:3000';
const VALID_TOKEN = 'test-token-valid';
const INVALID_TOKEN = 'not-a-real-token';

describe('bearer token authentication', () => {
  async function createAuthenticatedClient(token: string) {
    const transport = new SSEClientTransport(
      new URL(`${BASE_URL}/sse`),
      {
        requestInit: {
          headers: { Authorization: `Bearer ${token}` }
        }
      }
    );
    const client = new Client({ name: 'test', version: '1.0' });
    await client.connect(transport);
    return client;
  }

  it('allows connection with valid token', async () => {
    const client = await createAuthenticatedClient(VALID_TOKEN);
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
    await client.close();
  });

  it('rejects connection with invalid token', async () => {
    await expect(
      createAuthenticatedClient(INVALID_TOKEN)
    ).rejects.toThrow();
  });

  it('rejects connection with no token', async () => {
    const transport = new SSEClientTransport(new URL(`${BASE_URL}/sse`));
    const client = new Client({ name: 'test', version: '1.0' });
    await expect(client.connect(transport)).rejects.toThrow();
  });

  it('rejects connection with malformed Authorization header', async () => {
    const transport = new SSEClientTransport(
      new URL(`${BASE_URL}/sse`),
      {
        requestInit: {
          headers: { Authorization: VALID_TOKEN } // Missing "Bearer " prefix
        }
      }
    );
    const client = new Client({ name: 'test', version: '1.0' });
    await expect(client.connect(transport)).rejects.toThrow();
  });
});

Important: Test the actual HTTP status codes your server returns. A 401 is correct for "unauthenticated." A 403 is correct for "authenticated but not authorized." Mixing these up confuses clients and agents.

it('returns 401 for missing token', async () => {
  const response = await fetch(`${BASE_URL}/sse`);
  expect(response.status).toBe(401);
});

it('returns 401 for invalid token', async () => {
  const response = await fetch(`${BASE_URL}/sse`, {
    headers: { Authorization: 'Bearer invalid' }
  });
  expect(response.status).toBe(401);
});

Testing OAuth 2.0 Flows

MCP 1.x introduced OAuth 2.1 as the standard auth mechanism for HTTP-based servers. Testing OAuth flows requires mocking the authorization server.

import nock from 'nock';

describe('OAuth token validation', () => {
  const AUTH_SERVER = 'https://auth.example.com';

  beforeEach(() => {
    // Mock the OAuth token introspection endpoint
    nock(AUTH_SERVER)
      .post('/oauth/introspect')
      .reply(200, {
        active: true,
        sub: 'user-123',
        scope: 'read write',
        exp: Math.floor(Date.now() / 1000) + 3600
      });
  });

  afterEach(() => nock.cleanAll());

  it('accepts a valid OAuth token', async () => {
    const client = await createAuthenticatedClient('valid-oauth-token');
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
    await client.close();
  });
});

describe('OAuth token expiry', () => {
  it('rejects an expired token', async () => {
    nock('https://auth.example.com')
      .post('/oauth/introspect')
      .reply(200, {
        active: false // Token is expired or revoked
      });

    await expect(
      createAuthenticatedClient('expired-token')
    ).rejects.toThrow();
  });

  it('handles auth server being unavailable', async () => {
    nock('https://auth.example.com')
      .post('/oauth/introspect')
      .replyWithError('Connection refused');

    // Server should fail closed — deny access when auth server is down
    await expect(
      createAuthenticatedClient('any-token')
    ).rejects.toThrow();
  });
});

The critical security property to test: When the auth server is unavailable, your MCP server should deny access (fail closed), not grant it (fail open). Fail-open auth is a common misconfiguration that's easy to introduce and hard to notice.

Testing Authorization Scopes

Authentication proves identity. Authorization determines what an identity can do. MCP servers should restrict tool access based on the authenticated user's permissions.

const ADMIN_TOKEN = 'admin-token'; // Has all scopes
const READ_TOKEN = 'read-only-token'; // Has read scope only
const NO_SCOPE_TOKEN = 'no-scope-token'; // Authenticated but no useful scopes

describe('tool authorization by scope', () => {
  it('admin can call write tools', async () => {
    setupMockAuth(ADMIN_TOKEN, { scopes: ['read', 'write', 'admin'] });
    const client = await createAuthenticatedClient(ADMIN_TOKEN);
    
    const result = await client.callTool({
      name: 'write-file',
      arguments: { path: '/tmp/test.txt', content: 'hello' }
    });
    
    expect(result.isError).toBe(false);
    await client.close();
  });

  it('read-only user cannot call write tools', async () => {
    setupMockAuth(READ_TOKEN, { scopes: ['read'] });
    const client = await createAuthenticatedClient(READ_TOKEN);
    
    const result = await client.callTool({
      name: 'write-file',
      arguments: { path: '/tmp/test.txt', content: 'hello' }
    });
    
    // Should get an error result, not a crash
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('permission');
    await client.close();
  });

  it('read-only user can call read tools', async () => {
    setupMockAuth(READ_TOKEN, { scopes: ['read'] });
    const client = await createAuthenticatedClient(READ_TOKEN);
    
    const result = await client.callTool({
      name: 'read-file',
      arguments: { path: '/tmp/fixture.txt' }
    });
    
    expect(result.isError).toBe(false);
    await client.close();
  });

  it('tool list reflects accessible tools for the user', async () => {
    setupMockAuth(READ_TOKEN, { scopes: ['read'] });
    const client = await createAuthenticatedClient(READ_TOKEN);
    
    const { tools } = await client.listTools();
    const toolNames = tools.map(t => t.name);
    
    // Read-only user should see read tools
    expect(toolNames).toContain('read-file');
    
    // Read-only user should NOT see write tools (or they should be marked as restricted)
    // Choose one approach and be consistent:
    // Option A: Hide restricted tools from list
    // expect(toolNames).not.toContain('write-file');
    // Option B: Show all tools, return error on unauthorized call
    // (Already tested above)
    
    await client.close();
  });
});

There's a design decision here: should restricted tools be hidden from the tool list, or visible but returning errors when called? Both are valid. The important thing is to pick one and test it consistently.

Testing Tenant Isolation

If your MCP server is multi-tenant — serving multiple users or organizations — test that one user cannot access another user's data.

describe('tenant isolation', () => {
  it('user A cannot read user B resources', async () => {
    setupMockAuth('token-user-a', { userId: 'user-a', scopes: ['read'] });
    const clientA = await createAuthenticatedClient('token-user-a');
    
    // Create a resource as user A
    await clientA.callTool({
      name: 'create-document',
      arguments: { title: 'User A Private Doc', content: 'secret' }
    });
    
    // Try to read that resource as user B
    setupMockAuth('token-user-b', { userId: 'user-b', scopes: ['read'] });
    const clientB = await createAuthenticatedClient('token-user-b');
    
    // List resources as user B — should not see user A's documents
    const { resources } = await clientB.listResources();
    const resourceNames = resources.map(r => r.name);
    expect(resourceNames).not.toContain('User A Private Doc');
    
    await clientA.close();
    await clientB.close();
  });

  it('resource IDs are not guessable (or are access-controlled)', async () => {
    // If user A has resource with a predictable ID like "doc-1",
    // user B should get an error when trying to access it, not the resource.
    setupMockAuth('token-user-b', { userId: 'user-b', scopes: ['read'] });
    const clientB = await createAuthenticatedClient('token-user-b');
    
    const result = await clientB.callTool({
      name: 'read-document',
      arguments: { id: 'doc-1' } // User A's document
    });
    
    expect(result.isError).toBe(true);
    await clientB.close();
  });
});

Testing Token Refresh

For OAuth with short-lived access tokens, test that your client handles token refresh correctly.

describe('token refresh', () => {
  it('automatically refreshes expired token during operation', async () => {
    let callCount = 0;
    
    // First call: token is valid
    // Second call: token is expired, server returns 401
    // Third call: after refresh, succeeds
    nock('https://auth.example.com')
      .post('/oauth/token')
      .reply(200, {
        access_token: 'new-access-token',
        expires_in: 3600
      });

    // Your MCP client wrapper that handles refresh
    const client = new AuthenticatedMcpClient({
      serverUrl: BASE_URL,
      accessToken: 'expiring-token',
      refreshToken: 'valid-refresh-token',
      authServerUrl: 'https://auth.example.com'
    });

    // Should succeed by refreshing the token transparently
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
  });
});

Auth Error Messages and Security

Test that your auth errors don't leak information:

describe('auth error messages', () => {
  it('error message does not reveal whether user exists', async () => {
    // Both "user doesn't exist" and "wrong password" should return the same message
    const response1 = await fetch(`${BASE_URL}/sse`, {
      headers: { Authorization: 'Bearer nonexistent-user-token' }
    });
    const response2 = await fetch(`${BASE_URL}/sse`, {
      headers: { Authorization: 'Bearer wrong-password-token' }
    });

    expect(response1.status).toBe(401);
    expect(response2.status).toBe(401);
    
    const body1 = await response1.text();
    const body2 = await response2.text();
    expect(body1).toBe(body2); // Same error message for both
  });

  it('does not expose internal errors in auth responses', async () => {
    // Simulate database error during auth
    mockAuthService.mockRejectedValueOnce(new Error('Connection to auth DB timed out'));
    
    const response = await fetch(`${BASE_URL}/sse`, {
      headers: { Authorization: 'Bearer any-token' }
    });
    
    const body = await response.text();
    expect(body).not.toContain('Connection to auth DB');
    expect(body).not.toContain('timed out');
    expect(body).not.toContain('stack');
  });
});

Internal errors in auth responses are a common information leak. The error message an attacker gets from a failed auth attempt should never reveal anything about your system internals.

Running Auth Tests Safely

Auth tests require special care in CI:

- name: Run auth integration tests
  run: npm run test:auth
  env:
    NODE_ENV: test
    AUTH_SERVER_URL: http://localhost:8080 # Mock auth server
    MCP_TOKEN_SIGNING_SECRET: test-secret-not-for-production
    # Never use real credentials in CI test env

Use a mock auth server (or nock for HTTP mocking) — never real OAuth credentials in CI. Keep separate .env.test and .env.production files, and make sure the test secret is obviously not a real secret.

The Auth Testing Checklist

Before shipping an authenticated MCP server:

  • Valid token: connection succeeds, tools accessible
  • Invalid token: connection rejected with 401
  • Missing token: connection rejected with 401
  • Expired token: connection rejected with 401
  • Auth server unavailable: connection denied (fail closed)
  • Unauthorized scope: tool call returns isError: true, not server crash
  • Tenant isolation: user A cannot access user B's resources
  • Error messages: no internal details leaked
  • Concurrent authenticated sessions: no session mixing

Authentication bugs in MCP servers are security vulnerabilities, not feature gaps. Test every failure path before shipping.

Read more