The server-side proxy is only as secure as its weakest link. This guide covers the most common mistakes and how to avoid them.
The single most important security consideration: where does the email come from? If an attacker can control the email value, they can generate a token for any user in your community.
Critical vulnerability
Never read the user's email from a query parameter, form field, or request body that the client controls. Always derive it from your verified server-side session.
// WRONG - anyone can request any user's token
app.get('/widget', async (req, res) => {
const email = req.query.email; // attacker sets ?email=admin@company.com
// ...
});// CORRECT - email comes from a verified session
app.get('/widget', requireAuth, async (req, res) => {
const email = req.user.email; // from your auth middleware
// ...
});The /widget endpoint returns authenticated HTML. It must be behind your auth middleware - the same way you protect any authenticated page.
If the widget endpoint is fetched via AJAX (e.g. loaded into a modal), restrict CORS to your own domain.
const cors = require('cors');
app.use('/widget', cors({
origin: ['https://your-app.com'],
credentials: true,
}));Each widget request triggers two upstream API calls to Returning.AI. Rate-limit the endpoint to prevent abuse and protect your API quota.
const rateLimit = require('express-rate-limit');
const widgetLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute per IP
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false,
});
app.get('/widget', widgetLimiter, requireAuth, async (req, res) => {
// ...
});If you allow the widget type or ID to vary, validate them against an allowlist. Never interpolate raw user input into the upstream URL.
// Validate widget type to prevent SSRF
const ALLOWED_TYPES = ['store', 'channel', 'milestone', 'social', 'currency', 'minigames'];
if (!ALLOWED_TYPES.includes(WIDGET_TYPE)) {
return res.status(400).json({ error: 'Invalid widget type' });
}
// Never interpolate user input into the fetch URL
// WRONG: fetch(`${BASE}/${req.query.type}/${req.query.id}`)
// CORRECT: fetch(`${BASE}/${WIDGET_TYPE}/${WIDGET_ID}`)Internal error details (stack traces, upstream URLs, API key fragments) must never reach the client.
// Don't leak internal details to the client
app.get('/widget', requireAuth, async (req, res) => {
try {
// ... proxy logic
} catch (error) {
// Log the real error server-side
console.error('Widget proxy error:', error);
// Return a generic message to the client
res.status(500).json({ error: 'Unable to load widget' });
}
});Review this list before going to production.
Errors returned by the POST /widget/{communityId}/signin endpoint.
| Status | Meaning | Common cause |
|---|---|---|
401 | Unauthorized | Missing or invalid returningai-api-key header. |
404 | Community not found | The communityId in the URL does not match any active community. |
404 | User not found | The email header does not match a registered user. The user may need to be synced or created first. |
429 | Rate limited | Too many signin requests. Back off and retry with exponential delay. |
500 | Internal error | Transient server error. Retry once; if it persists, contact support. |