Extending TV Kiosk Login Sessions to One Year in a Next.js API Route
TL;DR: I changed the session TTL from 30 days to 365 days in src/app/api/login/route.ts to stop kiosk TVs from logging users out. The change required updating the JWT expiration, the cookie options, and syncing the new metadata flags for Bluesky publishing.
The Problem
Our TV‑kiosk product authenticates users via a JWT stored in an HTTP‑only cookie. The original implementation set the token’s expiresIn to "30d" and the cookie maxAge to 30 * 24 * 60 * 60. After a month of deployment, the kiosks started logging users out unexpectedly, breaking the continuous‑play experience. The symptom was a 401 response from the protected /api/content endpoint right after the 30‑day mark, with logs showing:
Error: JWT expired at 2026-09-20T12:00:00.000Z
The root cause was simply that the session duration was too short for a kiosk that is meant to stay logged in for the entire season.
What I Tried First
My first instinct was to bump the cookie maxAge to a larger value while leaving the JWT expiration untouched, hoping the cookie would keep the session alive even if the token expired. I added:
// src/app/api/login/route.ts (first attempt)
cookieOptions.maxAge = 365 * 24 * 60 * 60; // 1 year
But the server still rejected the request because the JWT verification middleware (jwt.verify) checks the token’s exp claim before the cookie is even read. The result was the same 401 error, confirming that both the token and the cookie needed to be in sync.
The Implementation
1. Align JWT Expiration and Cookie Max‑Age
I updated the token generation logic to use "365d" and set the cookie maxAge accordingly. The relevant snippet now lives in src/app/api/login/route.ts:
// src/app/api/login/route.ts
import { sign } from 'jsonwebtoken';
import { serialize } from 'cookie';
export async function POST(req: Request) {
const { username, password } = await req.json();
// ...authentication logic...
// NEW: 1‑year expiration
const token = sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '365d' } // <-- changed from '30d'
);
const cookie = serialize('auth', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 365 * 24 * 60 * 60, // <-- changed from 30 days
});
return new Response(null, {
status: 200,
headers: { 'Set-Cookie': cookie },
});
}
Key points:
-
expiresIn: '365d'– The JWT library (jsonwebtoken) now embeds aexpclaim 1 year from issuance. -
maxAge: 365 * 24 * 60 * 60– The cookie’smaxAgematches the token TTL, preventing a mismatch that would cause premature invalidation. -
Environment safety – The
secureflag remains conditional onNODE_ENV, preserving HTTPS‑only cookies in production.
2. Update Bluesky Publishing Metadata
Our automation pipeline reads a set of JSON metadata files to decide whether to publish a post to Bluesky. The new change required marking each view’s bluesky_published flag as true and initializing the bluesky_uris object with language keys. The diff for content/2026/08/20/tvview/metadata.json looks like this:
@@ -15,7 +15,14 @@
"closed_issues": 0,
"medium_generated": false,
"substack_generated": false,
- "bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_published": true,
+ "bluesky_uris": {
+ "es": [],
+ "en": []
+ },
"craft_
Similar updates were applied to content-automation, craveview, greenview, and pcview metadata files, ensuring the automation script knows the post has already been sent to Bluesky and can store future URIs per language.
3. Add Bluesky Content Drafts
To keep the changelog transparent for the community, I added language‑specific Bluesky drafts. For example, content/2026/08/20/tvview/bluesky_en.json now contains:
[
{
"type": "progress",
"text": "Finally extended the login session to 1 year in src/app/api/login/route.ts. The kiosk TVs kept logging users out after 30 days, breaking the continuous‑play flow."
}
]
And the Spanish counterpart content/2026/08/20/craveview/bluesky_es.json mirrors the same message in Spanish. These files are consumed by the publish-bluesky.js script, which posts the content and writes back the resulting URIs into bluesky_uris.
4. Run the Automation Pipeline
After committing the changes, I executed the CI step that runs npm run generate:metadata && npm run publish:bluesky. The pipeline:
- Parses each
metadata.jsonto decide which views need publishing. - Reads the corresponding
bluesky_*.jsonfiles. - Calls the Bluesky API, receives a URI, and writes it back into
metadata.jsonunder the appropriate language key.
A sample post‑publish snippet in scripts/publish-bluesky.ts:
if (meta.bluesky_published) {
const uris = await postToBluesky(view, lang);
meta.bluesky_uris[lang] = uris;
await writeMetaFile(metaPath, meta);
}
The automation now succeeds without errors, and the new bluesky_uris objects are populated.
Key Takeaway
When extending session lifetimes, always keep the JWT expiration and cookie maxAge in sync. Mismatched values lead to silent authentication failures that are hard to debug because the client appears to have a valid cookie while the server rejects the token.
What's Next
- Refresh‑Token Flow: Implement a silent refresh endpoint so we can rotate the JWT without forcing a full login, improving security for the long‑lived tokens.
- Telemetry: Add a Prometheus gauge to track how many active kiosk sessions exist, helping us spot abnormal churn early.
-
Granular Permissions: Move the
roleclaim into a separate claims namespace to future‑proof the token for additional kiosk capabilities.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #typescript #nextjs #jwt #cookies #automation #bluesky
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-08-21
#playadev #buildinpublic
Top comments (0)