typescript
46 lines · 8 steps
Decoding a JWT to check expiry
Parse a JWT's payload without a library and decide whether it has expired, allowing for clock skew.
Explained by
highlit
1interface JwtPayload {
2 exp?: number;
3 iat?: number;
4 sub?: string;
5 [key: string]: unknown;
6}
7
8function decodeBase64Url(segment: string): string {
9 const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
10 const withPadding = padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "=");
11 if (typeof atob === "function") {
12 return decodeURIComponent(
13 atob(withPadding)
14 .split("")
15 .map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
16 .join(""),
17 );
18 }
19 return Buffer.from(withPadding, "base64").toString("utf-8");
20}
21
22function parseJwtPayload(token: string): JwtPayload {
23 const parts = token.split(".");
24 if (parts.length !== 3) {
25 throw new Error("Malformed JWT: expected 3 segments");
26 }
27 let payload: unknown;
28 try {
29 payload = JSON.parse(decodeBase64Url(parts[1]));
30 } catch {
31 throw new Error("Malformed JWT: payload is not valid JSON");
32 }
33 if (typeof payload !== "object" || payload === null) {
34 throw new Error("Malformed JWT: payload is not an object");
35 }
36 return payload as JwtPayload;
37}
38
39export function isTokenExpired(token: string, clockSkewSeconds = 30): boolean {
40 const { exp } = parseJwtPayload(token);
41 if (typeof exp !== "number" || !Number.isFinite(exp)) {
42 throw new Error("JWT is missing a valid 'exp' claim");
43 }
44 const nowSeconds = Math.floor(Date.now() / 1000);
45 return nowSeconds >= exp - clockSkewSeconds;
46}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1JWTs are just base64url-encoded JSON, so you can inspect claims without a crypto library.
- 2Validate structure at each stage and narrow unknown types before trusting decoded data.
- 3A clock-skew allowance prevents flapping expiry decisions between machines with slightly different clocks.
Related explainers
ruby
module AppConfig module_function def fetch(key, default: nil, required: false)
Typed environment variable config in Ruby
environment-variables
type-coercion
configuration
Intermediate
7 steps
ruby
class ThumbnailPool def initialize(worker_count: 4, capacity: 100) @queue = SizedQueue.new(capacity) @running = true
A thread pool for thumbnail jobs in Ruby
concurrency
thread-pool
bounded-queue
Advanced
7 steps
typescript
import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core'; interface Shortcut { key: string;
A keyboard shortcut directive in Angular
directives
event-handling
keyboard-shortcuts
Intermediate
9 steps
go
package middleware import ( "net/http"
Localized validation errors in Gin
middleware
internationalization
validation
Intermediate
8 steps
typescript
import { Component, computed, signal } from '@angular/core'; import { CdkTableModule } from '@angular/cdk/table'; import { CdkScrollableModule } from '@angular/cdk/scrolling';
Paginating a CDK table with Angular signals
signals
computed-state
pagination
Intermediate
9 steps
javascript
async function uploadInBatches(records, uploadFn, { batchSize = 100, concurrency = 3 } = {}) { const batches = []; for (let i = 0; i < records.length; i += batchSize) { batches.push(records.slice(i, i + batchSize));
Uploading records with bounded concurrency
concurrency
worker-pool
async-await
Advanced
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/decoding-a-jwt-to-check-expiry-explained-typescript-e786/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.