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
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
python
import time import uuid from django.utils.deprecation import MiddlewareMixin
Attaching per-request context in Django
middleware
request lifecycle
multi-tenancy
Intermediate
7 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
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.