Authentication with JWT
Learning Objectives
- You understand the structure of JSON Web Tokens (JWTs) and their three components.
- You can implement JWT-based authentication with token expiration.
JSON Web Tokens
JSON Web Tokens (JWTs) are a compact way of securely transmitting information as a JSON object, commonly used for authentication and passing information. A JWT consist of three parts: a header, a payload, and a signature:
- The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HS256 for HMAC-SHA256
- The payload contains the claims. Claims are statements about an entity (typically, the user) and additional data such as the expiration time for the token (defined by the
expclaim). - The signature is used to verify that the token has not been tampered with. The signature is created by taking the encoded header, the encoded payload, a secret, the algorithm specified in the header, and signing that. Only those who have the secret key (typically, the server) can verify (and create) the signature.
The header and payload are Base64 encoded JSON objects, while the signature is an encoded string. Jointly, the three parts form a JWT, which is represented as a string in the format header.payload.signature.
When using JWTs, the server does not need to store session data about the user. Instead, the server can verify the token by recomputing its signature using the secret key. If the signature and claims are valid, the server can trust the data in the payload (such as a user ID or email) without a database lookup.
Note that JWTs are not encrypted by default. Their contents are readable by anyone with the token, so do not include sensitive data like passwords or secrets into the token. This is illustrated in the next exercise, where you use an online service to decode a JWT.
Authentication with JWTs
Here, we modify the authentication API from the last chapter to use JWTs. The API will create a JWT token when the user logs in, and return the token with the user information to the client.
Hono JWT Helpers
Hono comes with JWT Helpers that make it easier to work with JWTs on the server. To use the helpers, we import the functions exposed from @hono/hono/jwt, giving them the alias jwt.
import * as jwt from "@hono/hono/jwt";
The helper exposes three asynchronous functions that we’ll use:
sign(payload, secret)that creates a signed JWTverify(token, secret)that verifies a JWTdecode(token)that decodes a JWT without verifying it
The example below shows how to use the helper to create a JWT token.
import * as jwt from "@hono/hono/jwt";
const payload = {
message: "Hello world!",
};
const token = await jwt.sign(payload, "SECRET KEY");
console.log(token);
The output of the above program would be as follows:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiSGVsbG8gd29ybGQhIn0.ZIGQVXBhPBOdz_yucR6j-ULF3TvCgNrmWU9Y6iI_DTk
Authentication controller
Let’s modify the authentication controller to use JWTs. First, we import the JWT helpers as shown above. Then, when the user successfully authenticates, we create a payload that contains the user email, and sign the payload using the secret key to create a token. Finally, we respond with the token and the user information.
In this example, we store the secret key in a constant
JWT_SECRETwith the value “jwt_secret”. In practice though, the secret would not be hardcoded in the source code, but instead be stored in an environment variable. The secret would also be more complex than “jwt_secret”.
The following example outlines the changed authController.js.
import * as jwt from "@hono/hono/jwt";
import { hash, verify } from "scrypt";
import * as authRepository from "./authRepository.js";
const JWT_SECRET = "jwt_secret";
const register = async (c) => {
const user = await c.req.json();
user.password_hash = hash(user.password);
const newUser = await authRepository.create(user);
return c.json(newUser);
};
const login = async (c) => {
const user = await c.req.json();
const foundUser = await authRepository.findByEmail(user.email);
if (!foundUser) {
return c.json({ error: "Invalid email or password" }, 401);
}
const isValid = verify(user.password, foundUser.password_hash);
if (!isValid) {
return c.json({ error: "Invalid email or password" }, 401);
}
const payload = { email: foundUser.email };
const token = await jwt.sign(payload, JWT_SECRET);
return c.json({
message: "Login successful",
user: { email: foundUser.email },
token
});
};
export { login, register };
Now, when we try authenticating, the response has a cookie with the JWT token as the value.
curl -X POST -d '{"email": "test@test.com", "password": "secret"}' localhost:8000/api/auth/login
{"message":"Login successful","user":{"email":"test@test.com"},"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJzZWNyZXQiOiJJIGFtIGhhcHB5IHRoYXQgeW91IGxvb2tlZCBpbnRvIHRoaXMgOikuIn0.v9652aAhEoCyLXCUX7QpHEXX4wO77-QujN9LYHBRkUs"}
In the above, the user email is included in both the user object and the token. This way the client can use the user object directly without needing to decode the token. However, the client can also decode the token to get the user email.
JWT Expiration
By default, the JWT tokens do not expire. They should not be valid forever, though, as that would be a security risk — if a token is compromised, it could be used indefinitely.
Expiration time
To improve security, we can set an expiration time for the tokens.
The expiration of a token can be set when the token is created using an exp property in the payload. The exp property should be set to a timestamp (in seconds) that indicates when the token should expire. As an example, if we would wish to create a token that expires in 60 seconds, we could set the exp property to the current time plus 60 seconds.
const payload = {
email: foundUser.email,
exp: Math.floor(Date.now() / 1000) + 60,
};
Normally, the expiration time would be longer than 60 seconds, for example, one day or one week.
Access and refresh tokens
Contemporary applications use two tokens to manage authentication: access tokens and refresh tokens. Access tokens are short-lived tokens used to access protected resources, while refresh tokens are long-lived tokens used to obtain new access tokens when the current access token expires.
Access and refresh tokens are used e.g. in OAuth 2.0 authorization framework.
With access and refresh tokens, the flow is as follows: When the user logs in, they receive both an access token and a refresh token. Then, when the user interacts with the system, the client uses the access token to authenticate API requests. If the access token expires, the client can use the refresh token to request a new access token without requiring the user to log in again. If the refresh token is also expired, the user must log in again.
The rationale for using both access and refresh tokens is twofold: first, access tokens are only valid for a short time, and if compromised, the risk of misuse is lower. This is important as access tokens are primarly the tokens that are sent with API requests. Second, refresh tokens improve the user experience, as the user does not need to log in frequently.
In this course, we will not implement refresh tokens. However, you should be aware of their existence and purpose.
Summary
In summary:
- JSON Web Tokens (JWTs) consist of three parts: header, payload, and signature, formatted as
header.payload.signature:- The header contains metadata about the token, including the token type and signing algorithm.
- The payload contains claims about the user and additional data, such as email and expiration time.
- The signature ensures the token hasn’t been tampered with and can only be created and verified by those with the secret key (e.g. the server).
- JWTs are not encrypted by default so anyone who gains access to the tokens can decode and read the payload.
- Contemporary applications often use two types of tokens: short-lived access tokens for accessing resources and long-lived refresh tokens for obtaining new access tokens without re-authentication.