MCP Authentication: What I Got Wrong Building a Production MCP Server for My Knowledge Base
Honestly, I thought I had authentication figured out after 74 articles about building MCP servers. I've written about design lessons, error handling, architecture choices... but authentication? I completely underestimated how messy this gets in the real world.
Let me tell you the story. I spent three days debugging why my MCP server worked perfectly in Claude Desktop but failed 100% of the time in other MCP clients. I checked endpoints, I checked JSON formatting, I checked CORS... everything looked fine. But clients kept getting "unauthorized" or just hanging.
Turns out, the problem wasn't my auth logic. It was that different MCP clients expect auth information in completely different places. And the spec... well, let's just say it's still evolving. If you're building an MCP server right now, save yourself three days of headache. Here's everything I learned the hard way.
The Problem: Everyone Does Auth Differently
Here's what I naively implemented first:
@GetMapping("/mcp/tools/call")
public ResponseEntity<McpResponse> callTool(
@RequestHeader("X-API-Key") String apiKey,
@RequestBody McpRequest request
) {
if (!validateApiKey(apiKey)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// process request
}
Makes sense, right? Standard API key in a header. Claude Desktop was happy. Everything worked. I deployed to production, added it to my MCP client config, and... nothing. 401 Unauthorized. Every. Single. Time.
I checked the config ten times. I copied the key correctly. I checked ngrok forwarding. I checked firewall rules. Nothing. Then I looked at what the client was actually sending.
Turns out, this particular client sends the API key as a query parameter:
https://my-server.com/mcp/tools/call?api_key=abc123
Oh. Okay. Fine. I added query param support. That should fix it, right? Nope. Still not working for another client. That one sends it in the Authorization header as a bearer token:
Authorization: Bearer abc123
Are you kidding me? At this point I'm laughing. Three different clients, three different places for the API key. The MCP spec doesn't actually mandate where to put it - it just says "authenticate your requests". So everyone does their own thing.
The Solution: Support All Three (Yes, Really)
I learned the hard way: if you want your MCP server to work with any client, you need to check all three locations. Let me show you the working code I ended up with:
@Component
public class McpAuthFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
HttpServletResponse httpRes = (HttpServletResponse) response;
String apiKey = extractApiKey(httpReq);
if (apiKey == null || !validateApiKey(apiKey)) {
httpRes.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or missing API key");
return;
}
chain.doFilter(request, response);
}
private String extractApiKey(HttpServletRequest request) {
// Check 1: X-API-Key header
String apiKey = request.getHeader("X-API-Key");
if (isValid(apiKey)) {
return apiKey;
}
// Check 2: Authorization: Bearer header
apiKey = request.getHeader("Authorization");
if (apiKey != null && apiKey.startsWith("Bearer ")) {
apiKey = apiKey.substring(7);
if (isValid(apiKey)) {
return apiKey;
}
}
// Check 3: api_key query parameter
apiKey = request.getParameter("api_key");
if (isValid(apiKey)) {
return apiKey;
}
// Check 4: apiKey query parameter (variation)
apiKey = request.getParameter("apiKey");
if (isValid(apiKey)) {
return apiKey;
}
return null;
}
private boolean isValid(String apiKey) {
return apiKey != null && !apiKey.isBlank();
}
private boolean validateApiKey(String apiKey) {
// Your validation logic here
return storedApiKey.equals(apiKey);
}
}
Then register the filter for your MCP endpoints:
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<McpAuthFilter> mcpAuthFilter() {
FilterRegistrationBean<McpAuthFilter> registration =
new FilterRegistrationBean<>(new McpAuthFilter());
registration.addUrlPatterns("/mcp/*");
registration.setOrder(1);
return registration;
}
}
That's it. Now this code will work with any MCP client I throw at it. Claude Desktop works with X-API-Key. Other clients work with bearer or query params. Everyone's happy.
But wait - there's more I learned that you need to know.
The Second Problem: API Key Storage in Client Configs
Here's another gotcha. Different clients have different formats for configuring the server. Let me show you what I've seen in the wild:
Claude Desktop format:
{
"mcpServers": {
"my-knowledge-base": {
"url": "https://my-server.com/mcp",
"headers": {
"X-API-Key": "your-api-key-here"
}
}
}
}
Another client format:
{
"servers": [
{
"url": "https://my-server.com/mcp?api_key=your-api-key-here",
"name": "my-knowledge-base"
}
]
}
Another client puts auth in the top-level:
{
"url": "https://my-server.com/mcp",
"auth": {
"type": "bearer",
"token": "your-api-key-here"
}
}
This isn't your problem as the server developer - clients handle configuration differently. But what is your problem is accepting the key wherever the client sends it. That's why supporting all three locations is non-negotiable if you want people to actually use your server.
Honestly, I think this fragmentation will settle down over time as the spec matures. But right now, in mid-2026, this is just the reality you have to deal with.
Should You Really Support Query Parameters?
I know what you're thinking: "Putting API keys in the URL is bad practice! They get logged in servers, proxies, browser history..."
You're absolutely right. It is bad practice. But here's the thing: some clients only support this. If you don't support it, those clients can't use your server at all.
So what's the trade-off? If you're building a private MCP server just for yourself (like I am with my knowledge base), it's fine. You're probably running it on your own infrastructure, you trust your logs. The convenience of working with more clients outweighs the slight security risk.
If you're building a public MCP service that other people will host... honestly, I'd still support it. Document that query params are less secure, recommend headers, but still support it for compatibility. Users will choose what works for their client.
In my case, I'm hosting my own knowledge base. I just want it to work everywhere. So query params stay.
The Third Problem: CORS Preflight Requests
Wait, there's more! If your MCP server is accessed from a browser-based client (many are these days), you need to handle CORS properly. And guess what? Preflight OPTIONS requests don't send headers or query params.
If you reject OPTIONS requests because there's no API key, your CORS will break and the actual request will never happen. I spent an extra hour debugging this one.
Here's how to fix it in Spring Boot:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/mcp/**")
.allowedOrigins("*") // Restrict to your clients in production
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*");
}
}
And update your auth filter to skip checking auth for OPTIONS:
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
// Skip auth for CORS preflight
if ("OPTIONS".equalsIgnoreCase(httpReq.getMethod())) {
chain.doFilter(request, response);
return;
}
// ... rest of your auth logic
}
That's one of those things you don't think about until it breaks everything. Don't forget this step. Ask me how I know.
Putting It All Together: The Complete Controller
Here's what my full MCP tools endpoint looks like now with everything working:
@RestController
@RequestMapping("/mcp")
public class McpController {
private final McpToolService toolService;
private final KnowledgeSearchService searchService;
public McpController(McpToolService toolService, KnowledgeSearchService searchService) {
this.toolService = toolService;
this.searchService = searchService;
}
@PostMapping("/tools/list")
public ResponseEntity<ListToolsResponse> listTools() {
// Auth already handled by filter
List<ToolInfo> tools = toolService.getAllTools();
return ResponseEntity.ok(new ListToolsResponse(tools));
}
@PostMapping("/tools/call")
public ResponseEntity<McpResponse> callTool(
@RequestBody ToolCallRequest request
) {
try {
McpResponse response = toolService.execute(request);
return ResponseEntity.ok(response);
} catch (Exception e) {
// Error handling we talked about in the last article
return ResponseEntity.ok(McpResponse.error(e.getMessage()));
}
}
}
Clean, simple, works everywhere. All the messy compatibility stuff is in the filter where it belongs.
Pros and Cons of This Approach
Let's be real - nothing's perfect. Here's what I like and what I don't like about this approach:
Pros:
- ✅ Works with every MCP client I've tested so far (Claude Desktop, 5 different web clients, two open source projects)
- ✅ Zero configuration needed for users - they just put their key where their client expects it
- ✅ Simple code, easy to maintain
- ✅ Separates auth from business logic (good design)
- ✅ Handles CORS preflight correctly out of the box
Cons:
- ❌ Slightly less secure when clients use query params (but that's the client's choice, not yours)
- ❌ You're supporting multiple conventions because the spec hasn't settled yet
- ❌ A tiny bit more code than just doing one auth method
For my use case - a personal knowledge base MCP server that I want to work from any client - this is absolutely the right trade-off. If you're building an MCP server for others to use, I think this is the right approach too. Make it work everywhere, document the best practices, and let users decide.
What I Would Do Differently Next Time
Looking back, what would I change? I'd implement multi-location auth from day one. I wouldn't just do what the first client does and assume everyone else follows. That was my big mistake. I assumed Claude Desktop was the standard, and everyone would follow their conventions. That was wrong.
The MCP ecosystem is still young. Clients are evolving quickly. Everyone's experimenting with different approaches. Right now, as a server developer, your job is to be compatible with all of them. It's not the client's job to adapt to you.
I also would have tested with multiple clients earlier. I got everything working with Claude, called it done, deployed... then hit the wall when I tried other clients. Lesson learned: test with at least two or three different clients before you declare auth "done".
Closing Thoughts
After building three MCP servers for different projects and hitting every auth-related wall you can hit, here's my advice:
- Check for API key in
X-API-Keyheader - Check for API key in
Authorization: Bearerheader - Check for API key in
api_keyquery parameter - Also check
apiKeyquery parameter just in case - Skip auth for OPTIONS preflight requests
- Test with multiple clients before you declare victory
That's it. That's the whole article. It's short, it's simple, but it would have saved me three days of debugging if someone had told me this three weeks ago.
The MCP ecosystem is exciting, it's moving fast, and that means things are fragmented. Embrace it. Make it work. Your users will thank you.
Have you built an MCP server? Hit any interesting auth issues I didn't mention here? What approach do you take? Drop a comment below - I'd love to hear what's working for you.
If you enjoyed this series on building production MCP servers, check out the Papers project on GitHub - it's my 1,800-hour personal knowledge base that I've been rebuilding for the MCP era. Star it if you find these articles helpful!
Top comments (0)