DEV Community

Roberto Luna
Roberto Luna

Posted on

Extending TV Kiosk Login Sessions to One Year in a Next.js API Route

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • expiresIn: '365d' – The JWT library (jsonwebtoken) now embeds a exp claim 1 year from issuance.
  • maxAge: 365 * 24 * 60 * 60 – The cookie’s maxAge matches the token TTL, preventing a mismatch that would cause premature invalidation.
  • Environment safety – The secure flag remains conditional on NODE_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_
Enter fullscreen mode Exit fullscreen mode

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."
  }
]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Parses each metadata.json to decide which views need publishing.
  2. Reads the corresponding bluesky_*.json files.
  3. Calls the Bluesky API, receives a URI, and writes it back into metadata.json under 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);
}
Enter fullscreen mode Exit fullscreen mode

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 role claim 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)