I recently shipped a production Twitch extension. This is the mistake that cost me the most time.
If you've tried to build a Twitch extension with a backend service (called an EBS — Extension Backend Service), you may have run into a wall of mysterious 401 errors that no amount of Stack Overflow searching seems to fix.
The cause is almost certainly this.
The Mistake
Twitch signs the JWTs it sends to your EBS using your extension's secret key. You verify these JWTs in your backend using a library like jsonwebtoken. Every tutorial you'll find shows something like this:
const secret = process.env.TWITCH_EXTENSION_SECRET;
app.use((req, res, next) => {
const token = req.headers.authorization?.slice(7);
const decoded = jwt.verify(token, secret); // 401 every time
});
This looks correct. It isn't.
What's Actually Happening
Go to your extension in the Twitch Developer Console and look at the extension secret on the Settings tab. What you're copying into your .env file is base64-encoded. Twitch encodes it in the console. jsonwebtoken expects the raw bytes.
When you pass the base64 string directly, the library decodes your JWT using the wrong key, signature verification fails, and you get a 401 on every single request.
The fix is one line:
// Decode the base64 string into raw bytes first
const secret = Buffer.from(process.env.TWITCH_EXTENSION_SECRET, 'base64');
app.use((req, res, next) => {
const token = req.headers.authorization?.slice(7);
const decoded = jwt.verify(token, secret); // works
});
The Full JWT Middleware
Here's a complete, production-ready middleware that handles the decode, verifies the token, and exposes role information to your route handlers:
const jwt = require('jsonwebtoken');
const secret = Buffer.from(process.env.TWITCH_EXTENSION_SECRET, 'base64');
function verifyTwitchJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
try {
req.twitch = jwt.verify(authHeader.slice(7), secret);
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function requireMod(req, res, next) {
const { role } = req.twitch;
if (role !== 'broadcaster' && role !== 'moderator') {
return res.status(403).json({ error: 'Moderator or broadcaster required' });
}
next();
}
The decoded JWT payload gives you channel_id, user_id, opaque_user_id, and role — everything you need to scope data per channel and gate endpoints by viewer role.
Three More Gotchas While You're Here
If you make it past the JWT issue, here's what's waiting for you.
PubSub silently drops messages over 1/second
Twitch's PubSub API enforces a rate limit of 1 message per second per channel. If you exceed it, messages are silently dropped — no error response, no retry, nothing. Your code looks like it's working and events just disappear.
The fix is a per-channel queue that drains at a safe interval:
const queues = new Map();
function broadcast(channelId, payload) {
if (!queues.has(channelId)) {
queues.set(channelId, [payload]);
drainQueue(channelId);
} else {
queues.get(channelId).push(payload);
}
}
function drainQueue(channelId) {
const queue = queues.get(channelId);
if (!queue?.length) { queues.delete(channelId); return; }
sendToTwitch(channelId, queue.shift());
setTimeout(() => drainQueue(channelId), 1100); // 1100ms = safely under 1/sec
}
Vite's base: './' is not optional
Twitch serves your extension frontend from a CDN path like /extensions/abc123/1.0.0/viewer.html. If you build with Vite's default config, asset paths in the output are absolute (/assets/main.js). Those 404 on the CDN.
Add base: './' to your vite.config.js so asset references become relative:
export default defineConfig({
base: './', // required for Twitch CDN
// ...
});
Cloudflare (and most proxies) cache your API responses
If you're routing your EBS through Cloudflare or another caching proxy, your API responses will be cached and served to every viewer regardless of their JWT. All your per-channel, per-viewer logic silently stops working.
Add Cache-Control: no-store to every dynamic endpoint:
router.get('/game', verifyTwitchJWT, async (req, res) => {
res.set('Cache-Control', 'no-store');
// ...
});
Skip the Week of Debugging
I packaged everything above — the JWT middleware, the PubSub queue, a React + Vite frontend configured correctly for the Twitch CDN, SQLite with migrations, Docker setup — into a scaffold you can build on.
GitHub (README + landing page): https://github.com/GothUncc/twitch-extension-scaffold
Full scaffold download: https://gothuncc.gumroad.com/l/gtaay
Top comments (0)