Introduction
JSON Web Tokens (JWTs) play a crucial role in web application security. In this blog, we walkthrough the concept of JWT, focusing on the different types of claims, the structure of a JWT, and the algorithms used in signatures, and finally I will implement JWT authentication from scratch in Node.js and Express.js.
This is my 4th article in Auth101! It’s 2024 now! Looking forward to a wonderful year filled with cool tech updates, new tricks in cyber security, and a bunch of fun coding adventures. I can’t wait to dive into more authentication topics with you all 😃
Understanding JWT
JSON Web Tokens (JWTs) originated as a compact and self-contained way for securely transmitting information between parties as a JSON object. Defined in RFC 7519, JWTs have become a widely adopted standard in the field of web security for their simplicity and versatility.
A JWT is a string comprising three parts separated by dots (.
): Base64Url encoded header, Base64Url encoded payload, and signature.
It typically looks like xxxxx.yyyyy.zzzzz
.
Let’s deep dive into the three parts: Header, Payload, and Signature.
Header
The header typically consists of the token type and the signing algorithm, such as HMAC SHA256 or RSA.
For example:{ "alg": "HS256", "typ": "JWT" }
Payload
The payload contains claims, which are statements about an entity and additional metadata. Claims are categorized into registered, public, and private claims. The later two are for custom claims. Public claims are collision-resistant while private claims are subject to possible collisions. In a JWT, a claim appears as a name/value pair where the name is always a string and the value can be any JSON value. For example, the following JSON object contains three claims (sub
, name
, admin
):
{ "sub": "1234567890", "name": "Tom Green", "admin": false }
> 1. Registered Claims
These are predefined claim names with specific meanings recommended for interoperability. For example:
iss
(Issuer): Identifies the principal that issued the JWT.sub
(Subject): Identifies the principal that is the subject of the JWT.aud
(Audience): Identifies the recipients that the JWT is intended for.exp
(Expiration Time): Identifies the expiration time on or after which the JWT must not be accepted for processing.nbf
(Not Before): Identifies the time before which the JWT must not be accepted for processing.iat
(Issued At): Identifies the time at which the JWT was issued.jti
(JWT ID): Unique identifier; can be used to prevent the JWT from being replayed (allows a token to be used only once).
You can see a full list of registered claims at the IANA JSON Web Token Claims Registry
> 2. Public Claims
These can be defined at will and should be registered in the IANA JSON Web Token Registry or defined as a URI.
> 3. Private Claims
These are custom claims created to share information between parties that agree on using them.
When creating custom claims for JWTs that are specific to your application, it’s often beneficial to use namespacing. This ensures that your claims are unique and do not conflict with other standard or custom claims. Here’s an example of how to implement namespaced custom claims:
{ "https://yourdomain.com/claims/user_type": "admin", "https://yourdomain.com/claims/access_level": "5" }
In this example, custom claims are prefixed with a URL (https://yourdomain.com/claims/
) that is under your control. This URL acts as a namespace, reducing the likelihood of your claims conflicting with others. The claims user_type
and access_level
are specific to the application and are namespaced to ensure uniqueness.
Signature
The signature is created by taking the encoded header, payload, and a secret, then signing it with the algorithm specified in the header. The signature verifies that the sender of the JWT is who it says it is and ensures that the message wasn’t changed along the way.
Example
An example JWT for a user john.doe
using HMAC SHA256 might look like this:
- Header:
{ "alg": "HS256", "typ": "JWT" }
- Payload:
{ "sub": "john.doe", "name": "John Doe", "admin": false, "iat": 1615070800 }
- Signature: Cryptographic signature generated from the header, payload, and secret key. We will see the implementation later.
Implementing JWT Authentication
Step 1: Setting Up the Node.js and TypeScript Environment
Please refer to the steps explained in our previous blog post Password Authentication In Node.Js: A Step-By-Step Guide at Step 1: Setting Up the Node.js and TypeScript Environment.
Step 2: Creating the Server
usersData.ts
This is same as the file usersData.ts we talked in A Deep Dive Into HTTP Basic Authentication except we added a new field called refreshToken
:
interface User { username: string; password: string; refreshToken?: string; } const users: User[] = []; export default users;
jwt.ts
I decided to do a custom implementation for generating and verifying JWTs (JSON Web Tokens) without using external libraries like jsonwebtoken
.
It mainly provided below functionalities:
Base64 URL Encoding Function (base64UrlEncode):
- Converts a
Buffer
object to a Base64 URL-encoded string. This is necessary because standard Base64 encoding includes characters (+
,/
, and=
) that are not URL-safe. The function replaces these characters to make the string URL-safe.
Signature Function (sign):
- Takes the encoded header, payload, and secret key, then generates a signature using HMAC SHA256.
- The resulting signature is then Base64 URL-encoded.
Generate Access Token Function (generateAccessToken):
- Creates a JWT with a header specifying the algorithm (
HS256
) and token type (JWT
). - The payload includes the
username
and anexp
(expiration time), set to 15 minutes from the current time. - The header and payload are Base64 URL-encoded and concatenated with a period, and then signed to generate the JWT.
Generate Refresh Token Function (generateRefreshToken):
- Similar to the access token, but the payload includes a longer expiration time (7 days) and an additional
type
field set to'refresh'
. - This token is used to obtain new access tokens without requiring the user to log in again.
Verify Token Function (verifyToken):
- Splits the JWT into its components (header, payload, signature).
- Regenerates the signature based on the header and payload from the token and compares it with the received signature.
- If the signatures match, the function returns the decoded payload; otherwise, it throws an error indicating an invalid token.
secretKey:
The secretKey
must be kept confidential and secure because it is essentially the "key" that locks and unlocks the JWTs. If an unauthorized party gains access to the secretKey
, they could potentially generate their own valid tokens or tamper with existing tokens, leading to security breaches.
const secretKey = 'your_secret_key'; // Use a strong secret key const base64UrlEncode = (str: Buffer): string => { return str.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); }; const sign = (header: string, payload: string, secret: string): string => { const signature = crypto.createHmac('SHA256', secret) .update(`${header}.${payload}`) .digest('base64'); return base64UrlEncode(Buffer.from(signature)); }; export const generateAccessToken = (username: string): string => { const header = { alg: 'HS256', typ: 'JWT' }; const payload = { username, exp: Math.floor(Date.now() / 1000) + (15 * 60) }; // 15 minutes expiry const encodedHeader = base64UrlEncode(Buffer.from(JSON.stringify(header))); const encodedPayload = base64UrlEncode(Buffer.from(JSON.stringify(payload))); const signature = sign(encodedHeader, encodedPayload, secretKey); return `${encodedHeader}.${encodedPayload}.${signature}`; }; export const generateRefreshToken = (username: string): string => { const header = { alg: 'HS256', typ: 'JWT' }; const payload = { username, type: 'refresh', exp: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60) }; // 7 days expiry const encodedHeader = base64UrlEncode(Buffer.from(JSON.stringify(header))); const encodedPayload = base64UrlEncode(Buffer.from(JSON.stringify(payload))); const signature = sign(encodedHeader, encodedPayload, secretKey); return `${encodedHeader}.${encodedPayload}.${signature}`; }; export const verifyToken = (token: string): any => { const [encodedHeader, encodedPayload, signature] = token.split('.'); const verifiedSignature = sign(encodedHeader, encodedPayload, secretKey); if (verifiedSignature !== signature) { throw new Error('Invalid token'); } return JSON.parse(Buffer.from(encodedPayload, 'base64').toString()); };
jwtAuthMiddleware.ts
This middleware is designed to handle JWT authentication for incoming HTTP requests.
export const jwtAuthMiddleware = (req: Request, res: Response, next: NextFunction) => { try { const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ error: 'Authorization header missing' }); } const token = authHeader.split(' ')[1]; const decodedUser = verifyToken(token); // Create a closure to pass the decoded user (req as any).getUser = () => decodedUser; next(); } catch (error) { res.status(401).json({ error: 'Invalid token' }); } };
app.ts
Now let’s assemble all in app.ts.
It includes routes for user registration, deletion, listing, login, token refresh, and accessing a protected route.
const app = express(); const PORT = 3001; app.use(bodyParser.json()); // User registration route app.post('/register', async (req: Request, res: Response) => { try { const { username, password } = req.body; // Check if the user already exists if (users.some((user) => user.username === username)) { return res.status(400).json({ error: 'Username already exists' }); } // Hash the password using bcrypt const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); // Save the user const newUser = { username, password: hashedPassword }; users.push(newUser); res.status(201).json({ message: 'User registered successfully!' }); } catch (error) { res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/user/:username', (req: Request, res: Response) => { const { username } = req.params; const userIndex = users.findIndex(user => user.username === username); if (userIndex === -1) { return res.status(404).json({ error: 'User not found' }); } users.splice(userIndex, 1); res.status(200).json({ message: `User ${username} deleted successfully` }); }); app.get('/users', (req: Request, res: Response) => { const usersWithoutPasswords = users.map(({ password, ...userWithoutPassword }) => userWithoutPassword); res.json(usersWithoutPasswords); }); // User login route app.post('/login', async (req: Request, res: Response) => { try { const { username, password } = req.body; const user = users.find((user) => user.username === username); if (!user) { return res.status(401).json({ error: 'Invalid username' }); } const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return res.status(401).json({ error: 'Invalid password' }); } const accessToken = generateAccessToken(username); const refreshToken = generateRefreshToken(username); res.json({ accessToken, refreshToken }); } catch (error) { res.status(500).json({ error: 'Internal server error' }); } }); // Refresh token route app.post('/refresh', (req: Request, res: Response) => { const { refreshToken } = req.body; try { const decoded = verifyToken(refreshToken); if (decoded.type !== 'refresh') { return res.status(401).json({ error: 'Invalid refresh token' }); } const newAccessToken = generateAccessToken(decoded.username); res.json({ accessToken: newAccessToken }); } catch (error) { res.status(401).json({ error: 'Invalid refresh token' }); } }); // Protected route app.get('/protected', jwtAuthMiddleware, (req: Request, res: Response) => { const user = (req as any).getUser(); res.json({ message: 'Protected route accessed', user }); }); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); });
Step 3: Testing the Server
Launch the server:
npx ts-node ./app.ts
Open another terminal and run below command:
echo '{"username": "testuser01", "password": "testpassword01"}' | http POST http://localhost:3001/register
It created a user testuser01
in the server with password testpassword01
.
Now we need to login with this user to get accessToken
and refreshToken
:
# echo '{"username": "testuser01", "password": "testpassword01"}' | http POST http://localhost:3001/login HTTP/1.1 200 OK Connection: keep-alive Content-Length: 365 Content-Type: application/json; charset=utf-8 Date: Mon, 15 Jan 2024 00:53:22 GMT ETag: W/"16d-IsPcbeAuThyiqhEWd7jZTpqMHlQ" Keep-Alive: timeout=5 X-Powered-By: Express { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyMDEiLCJleHAiOjE3MDUyODA5MDJ9.Q0MrUXA4RGI1Smc4SUFmYjV6UFlFRmhWL2NsV20rTHppSlpHemZjSWdsZz0", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyMDEiLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTcwNTg4NDgwMn0.TWozZlNvVnhBODJuUjFLc2JVcDRZT2hxZmFSNU9nR01MK3gvNTRnSlNWRT0" }
Let’s try to access the protected URI /protected
with the accessToken
:
❯ http GET http://localhost:3001/protected "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyMDEiLCJleHAiOjE3MDUyODA1NjN9.TXlFR0NUMFZKOXJRVTgvYzFaaGZ5R0JMSTAwdVF3YkNRN1dUa1FQbG9NVT0" HTTP/1.1 200 OK Connection: keep-alive Content-Length: 88 Content-Type: application/json; charset=utf-8 Date: Mon, 15 Jan 2024 00:50:57 GMT ETag: W/"58-CkXXzga6an0r8ICmEq1Q9VAps9I" Keep-Alive: timeout=5 X-Powered-By: Express { "message": "Protected route accessed", "user": { "exp": 1705280563, "username": "testuser01" } }
So far so good!
Now let’s use the refreshToken
to request a new accessToken
:
❯ http POST http://localhost:3001/refresh 'Content-Type:application/json' <<< '{"refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyMDEiLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTcwNTg4NDQ2M30.MDNHMzI0MEd5SXJCQXRZVCtxVEdCWVVOeDd5Z2F4cXlyaU9xYzB0dTFBWT0"}' HTTP/1.1 200 OK Connection: keep-alive Content-Length: 171 Content-Type: application/json; charset=utf-8 Date: Mon, 15 Jan 2024 00:53:09 GMT ETag: W/"ab-TBXJ1UxTtbjzvvrwtTfhDXlSc1Q" Keep-Alive: timeout=5 X-Powered-By: Express { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3R1c2VyMDEiLCJleHAiOjE3MDUyODA4ODl9.WkQzeWJTMU80R0dycFhNc1ZLWTVTZjBjbGZwTEpwMi96RFI1Mnh6ZkNIWT0" }
By the way, there are online JWT encoder/decoder you can use, for example https://www.jstoolset.com/jwt. Just paste the JWT string and it can help you to decode header
and payload
.
Summary
And there we have it — our exploration of JWTs is at a pause. I hope this journey has shed some light on the inner workings of JWT authentication and its role in securing web applications. But as they say, every end is a new beginning.
The source code of this tutorial has been uploaded to GeekCoding101 github repo as well, feel free to star my repo and explore.
Now, let’s ponder a common scenario: You log into a website and stay there, browsing around. Ever wondered how the server keeps recognizing you as you navigate from page to page? How does it ensure you still have access to all those protected areas without asking you to log in again and again? This isn’t just a matter of convenience; it’s a crucial aspect of user experience and security.
Is it something to do with sessions or cookies, perhaps? Well, that’s precisely the topic we’ll delve into in our next blog. We’ll unravel the mysteries of session management, cookies, and how they work together to maintain your authenticated state in a web application. It’s an essential piece of the puzzle for understanding comprehensive web security.
So stay tuned for our next discussion where we decode the secrets behind seamless and secure browsing experiences. Until then, happy coding, and keep those applications secure!