HeyPuter/puter · error · HttpError
forbidden
forbidden
Error message
Forbidden
What it means
`POST /turn/ingest-usage` is an internal-only endpoint authenticated via a shared secret (`config.peers.internal_auth_secret`). If that config value is missing, the endpoint immediately returns 403 — the feature is not set up, so no request can authenticate. This is a configuration guard, not a credential check failure.
Source
Thrown at src/backend/controllers/peer/PeerController.ts:231
throw new HttpError(500, 'TURN credential generation failed', {
legacyCode: 'internal_error',
});
}
const data = (await cfRes.json()) as { iceServers?: unknown };
res.json({ ttl, iceServers: data.iceServers });
};
/**
* POST /turn/ingest-usage — internal-only TURN egress metering. an external
* service that knows the usage information from cloudflare will send it to
* us here. Meters each record directly against the owning user via
* `services.metering.incrementUsage` multiplied by turn:egress-bytes cost.
*/
#ingestUsage = async (req: Request, res: Response): Promise<void> => {
const cfg = this.config.peers;
if (!cfg || !cfg.internal_auth_secret) {
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
}
const expectedSecret = cfg.internal_auth_secret;
const header = req.headers['x-puter-internal-auth'];
if (
!expectedSecret ||
typeof header !== 'string' ||
!secretsEqual(header, expectedSecret)
) {
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
}
const { records } = req.body ?? {};
if (!Array.isArray(records)) {
throw new HttpError(400, 'Missing `records` array', {
legacyCode: 'bad_request',
});
}
View on GitHub (pinned to 908ec23eda)
Solutions
- Set `peers.internal_auth_secret` to a strong random string shared between the server and the external usage-ingestion service.
- Ensure the external service sends the same value in the `x-puter-internal-auth` header.
- Restart the backend after updating config.
- If you don't run the usage-ingestion service, this endpoint returning 403 is expected and harmless.
Example fix
// before (config.json)
{ "peers": { "signaller_url": "..." } }
// after
{
"peers": {
"signaller_url": "...",
"internal_auth_secret": "<strong-random-secret>"
}
} Defensive patterns
Strategy: try-catch
Try / catch
// Internal service — catch 403 and alert ops that config is missing
try {
await fetch('/turn/ingest-usage', {
method: 'POST',
headers: { 'x-puter-internal-auth': secret },
body: JSON.stringify({ records }),
});
} catch (e) {
if (e.code === 'forbidden') {
console.error('internal_auth_secret not configured on server. Alert ops.');
}
} Prevention
- Self-hosters: set `peers.internal_auth_secret` before deploying the ingestion service.
- Share the secret securely between the server and ingestion service (not in version control).
- If you don't run the ingestion service, 403 here is expected and safe to ignore.
When it happens
Trigger: The internal usage-ingestion service calls the endpoint, but `config.peers` or `config.peers.internal_auth_secret` is not configured on the server. The first guard (`!cfg || !cfg.internal_auth_secret`) fires before the header is even checked.
Common situations: Self-hosting without configuring the internal auth secret; the external metering service is deployed but the server config was never updated; a new deployment that copied a minimal config template.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
AI-assisted analysis of HeyPuter/puter@908ec23eda (2026-08-12).
Data as JSON: /api/errors/2692252091c994c1.
Report an issue: GitHub.