Security Audit: Finding Auth Bypasses in an A2A Protocol App
A systematic security audit revealed 4 critical auth bypass vulnerabilities — here's how we found and fixed them

The Trigger
We were preparing Fable for production launch. The features were done, the UI looked great, and the A2A protocol was working. But before we flipped the switch, we decided to do a security audit.
What we found shocked us: 4 critical vulnerabilities that allowed unauthenticated users to access protected resources.
The Architecture
Fable uses a decoupled architecture with A2A (Agent-to-Agent) protocol:
Browser (Firebase Auth) → UI Express Proxy → Agent Service (Firebase Admin)
↓ ↓
No auth header? Accepts all requests?The audit focused on three questions:
1. Does the UI forward auth tokens to the Agent?
2. Does the Agent properly verify those tokens?
3. Are private resources protected?
Vulnerability 1: UI Proxy Not Forwarding Auth Tokens
The Problem
The UI has an Express proxy (ui/src/a2a/client.ts) that forwards requests to the Agent. But it was stripping the `Authorization` header:
// ui/src/a2a/client.ts (BEFORE)
const response = await fetch(`${AGENT_URL}/a2a/invoke`, {
method: "POST",
headers: { "Content-Type": "application/json" }, // No Authorization!
body: JSON.stringify({...}),
});Even though the UI had the user's Firebase token (from useAuth()), it never forwarded it to the Agent. This meant every request to the Agent was unauthenticated.
The Fix
Extract the Authorization header from incoming requests and forward it:
// ui/src/a2a/client.ts (AFTER)
const forwardRequest = (
req: Request,
endpoint: string,
options: RequestInit = {},
) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
// Forward Authorization header if present
if (req.headers.authorization) {
headers["Authorization"] = req.headers.authorization;
}
return fetch(`${AGENT_URL}${endpoint}`, {
...options,
headers,
});
};
// Apply to all route handlers
app.get("/api/stories", async (req, res) => {
const response = await forwardRequest(req, "/api/stories");
const data = await response.json();
res.json(data);
});Files modified: All route handlers in ui/src/a2a/client.ts (7 endpoints).
Vulnerability 2: Insecure Fallbacks in Auth Middleware
The Problem
The Agent's auth middleware (agent/src/middleware/auth.ts) had a dangerous fallback: if no token was provided (or verification failed), it would use req.body.userId or req.query.userId as the user ID:
// agent/src/middleware/auth.ts (BEFORE)
if (!authHeader || !authHeader.startsWith("Bearer ")) {
req.userId = req.body?.userId || (req.query.userId as string); // INSECURE!
return next(); // Proceeds WITHOUT auth!
}
// Lines 52-54: Same issue on verification failure
.catch(() => {
req.userId = req.body?.userId || (req.query.userId as string); // INSECURE!
next(); // Proceeds with invalid token!
});This meant anyone could pass `?userId=someone-else-id` and impersonate that user.
The Fix
Remove all fallbacks. If auth fails, return 401:
// agent/src/middleware/auth.ts (AFTER)
export function verifyFirebaseToken(
req: AuthRequest,
res: Response,
next: NextFunction,
) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({ error: "Unauthorized: Missing or invalid token" });
}
const idToken = authHeader.split("Bearer ")[1];
admin
.auth()
.verifyIdToken(idToken)
.then((decodedToken) => {
req.userId = decodedToken.uid;
next();
})
.catch((error) => {
return res.status(401).json({ error: "Unauthorized: Invalid token" });
});
}Vulnerability 3: Wildcard CORS Policy
The Problem
The Agent service allowed all origins to access it:
// agent/src/a2a/server.ts (BEFORE)
res.header("Access-Control-Allow-Origin", "*");This allowed any website to make requests to the Agent. Combined with Vulnerability 2, an attacker could:
1. Create a malicious site
2. Make authenticated-looking requests to your Agent
3. Access private data
The Fix
Use an environment variable for allowed origins:
// agent/src/config/env.ts
export const CORS_ORIGINS = process.env.CORS_ORIGINS?.split(",") || [
"http://localhost:3000",
];// agent/src/a2a/server.ts (AFTER)
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = CORS_ORIGINS;
if (origin && allowedOrigins.includes(origin)) {
res.header("Access-Control-Allow-Origin", origin);
} else if (process.env.NODE_ENV === "development") {
res.header("Access-Control-Allow-Origin", "*"); // Dev only!
}
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PATCH");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
return res.sendStatus(200);
}
next();
});Update agent/.env.example:
CORS_ORIGINS=http://localhost:3000,https://fable.example.comVulnerability 4: Missing Story Access Control
The Problem
The GET /api/stories/:id endpoint didn't verify if the requesting user:
1. Owns the story, OR
2. The story is marked as public
// agent/src/a2a/server.ts (BEFORE)
app.get("/api/stories/:id", async (req: AuthRequest, res: Response) => {
const storyDoc = await db.collection(collections.stories).doc(storyId).get();
if (!storyDoc.exists) {
return res.status(404).json({ error: "Story not found" });
}
return res.json({ story: storyDoc.data() }); // No access check!
});This meant private stories could be accessed by anyone who knows the story ID.
The Fix
Add ownership and visibility checks:
// agent/src/a2a/server.ts (AFTER)
app.get("/api/stories/:id", async (req: AuthRequest, res: Response) => {
try {
const storyId = req.params.id as string;
const storyDoc = await db
.collection(collections.stories)
.doc(storyId)
.get();
if (!storyDoc.exists) {
return res.status(404).json({ error: "Story not found" });
}
const storyData = storyDoc.data() as { userId: string; isPublic: boolean };
// Allow access if: story is public, OR user is authenticated and owns the story
if (!storyData.isPublic && storyData.userId !== req.userId) {
return res.status(403).json({ error: "Forbidden" });
}
return res.json({ story: storyData });
} catch (error) {
return res.status(500).json({
error: error instanceof Error ? error.message : "Unknown error",
});
}
});Public Endpoints: The Exception List
Not all endpoints should require auth. We created a whitelist of public paths:
const PUBLIC_PATHS = [
"/.well-known/agent-card.json", // A2A discovery
"/api/stories/public", // Public story grid
"/api/stories/all", // Discover page
"/health", // Health check
];And modified the auth middleware to handle them:
export function verifyFirebaseToken(
req: AuthRequest,
res: Response,
next: NextFunction,
) {
const publicPaths = [
"/api/stories/public",
"/api/stories/all",
"/.well-known/agent-card.json",
];
// For public paths, try to get userId if token provided, but don't require it
if (publicPaths.some((path) => req.path.startsWith(path))) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const idToken = authHeader.split("Bearer ")[1];
admin
.auth()
.verifyIdToken(idToken)
.then((decodedToken) => {
req.userId = decodedToken.uid;
next();
})
.catch(() => {
req.userId = undefined;
next(); // Continue without auth for public paths
});
} else {
req.userId = undefined;
next();
}
return;
}
// For protected paths, require auth (fail closed)
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
// ... rest of verification
}Verification Checklist
After fixing all vulnerabilities, test:
# 1. Agent rejects requests without token
curl http://localhost:8080/api/stories -v
# Should return 401 Unauthorized
# 2. UI proxy forwards token correctly
# Sign in to UI → DevTools → Network → Check Authorization header
# 3. Public stories still accessible
curl http://localhost:8080/api/stories/public
# Should work without token
# 4. Private story requires auth
curl http://localhost:8080/api/stories/{private_story_id}
# Should return 403 Forbidden without token
# 5. CORS allows only whitelisted origins
curl -H "Origin: http://evil.com" http://localhost:8080/api/stories/public -v
# Should NOT return Access-Control-Allow-Origin: http://evil.comLessons Learned
1. "Fail Closed" is Non-Negotiable
If authentication fails, always return 401. Never fall back to an unauthenticated state with a user-provided ID.
// ❌ WRONG
if (!token) {
req.userId = req.body.userId; // Attacker controls this!
next();
}
// ✅ RIGHT
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}2. Audit Your CORS Policy
Wildcard CORS (*) is never acceptable in production. Always whitelist specific origins:
| Environment | CORS Policy |
| ----------- | ------------------------- |
| Development | * (for convenience) |
| Production | Explicit origin whitelist |
3. Test Auth Flow End-to-End
Unit tests for auth middleware aren't enough. Test the full flow:
1. UI signs in → gets token
2. UI sends request with token
3. Proxy forwards token
4. Agent verifies token
5. Agent returns protected resource
4. Review All Endpoints for Access Control
Every endpoint that returns user-specific data needs:
// Checklist for every endpoint
const story = await getStory(storyId);
// ✅ Is the story public?
if (!story.isPublic) {
// ✅ Is the user authenticated?
if (!req.userId) return res.status(401).json({ error: "Unauthorized" });
// ✅ Does the user own this resource?
if (story.userId !== req.userId)
return res.status(403).json({ error: "Forbidden" });
}5. Use a Security Checklist for Every Release
We now use this checklist before every deployment:
- [ ] All endpoints require auth (except whitelisted public paths)
- [ ] No insecure fallbacks in auth middleware
- [ ] CORS policy uses origin whitelist
- [ ] User can only access their own data
- [ ] Public resources have explicit `isPublic` flag
- [ ] Tokens are forwarded correctly through all proxiesThe Takeaway
Security vulnerabilities often come from innocent-looking code. A simple "fallback for convenience" in auth middleware can compromise your entire user base.
The fix isn't just about changing code — it's about changing mindset. Assume every request is malicious until proven otherwise.
_Check more open source repos at: github.com/mnkrana/_