Understanding JWT, Refresh and Access Tokens

Elijah Koulaxis

July 12, 2024

json-web-tokens

In the modern web application world, securely managing user sessions is essential. Access tokens and refresh tokens are two often used methods for this. We'll go deep into the definition and operation of these tokens in this blog post, and we'll use Go's standard library to create a basic implementation from the ground up. We'll go over the creation, management, and validation processes for these tokens, providing thorough justifications and sample code.

What are Access Tokens

Definition: An access token is a short-lived token used to authenticate and authorize API requests.

Usage: When a user logs into an application, they receive an access token, which they must include in the header of their subsequent API requests.

Lifespan: Access tokens typically have a short lifespan (e.g., 15 minutes) to reduce the risk of misuse if compromised.

What are Refresh Tokens

Definition: A refresh token is a long-lived token used to obtain a new access token without requiring the user to re-authenticate.

Usage: When an access token expires, the refresh token is used to request a new access token.

Lifespan: Refresh tokens have a longer lifespan (e.g., days or weeks) compared to access tokens.

How They Work Together

Basic Implementation From The Ground Up

Let's build a simple implementation using Go's standard library. We'll cover token creation, validation, and refreshing without relying on any external libraries to better understand how it works in a high-level overvie to better understand how it works in a high-level overview.

What is a JWT

JSON Web Tokens (JWT) consist of three parts: Header, Payload, and Signature.

Creating Tokens

func createToken(username string, expiry time.Duration) (string, string, error) {
	accessTokenClaims := Claims{
		Username: username,
		Expiry:   time.Now().Add(expiry).Unix(),
	}

	accessToken, err := createJWT(accessTokenClaims)
	if err != nil {
		return "", "", err
	}

	refreshTokenClaims := Claims{
		Username: username,
		Expiry:   time.Now().Add(24 * time.Hour).Unix(),
	}

	refreshToken, err := createJWT(refreshTokenClaims)
	if err != nil {
		return "", "", err
	}

	return accessToken, refreshToken, nil
}

func createJWT(claims Claims) (string, error) {
	header := base64.StdEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))

	claimsJSON, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}

	payload := base64.StdEncoding.EncodeToString(claimsJSON)

	signature := createSignature(header, payload)

	token := fmt.Sprintf("%s.%s.%s", header, payload, signature)

	return token, nil
}

func createSignature(header, payload string) string {
	data := fmt.Sprintf("%s.%s", header, payload)
	h := hmac.New(sha256.New, []byte(secretKey))
	h.Write([]byte(data))

	return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

Let's take it step by step:

First of all, we create the header. The JWT header is a JSON object encoded in base64 format, specifying the type of token and the signing algorithm (HS256 in our case).

header := base64.StdEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))

Then, we create the payload, which contains the claims, such as the username and expiration time, also encoded in base64 format. Keep in mind that I'm only storing the username for simplicity, but you could store anything you want. Just keep in mind that the jwt should not be too large.

accessTokenClaims := Claims{
    Username: username,
    Expiry:   expiry,
}

claimsJSON, err := json.Marshal(accessTokenClaims)
payload := base64.StdEncoding.EncodeToString(claimsJSON)

Finally, we sign the whole thing. The signature ensures the token hasn’t been changed. It’s created by hashing the header and payload with a secret key using HMAC-SHA256.

signature := createSignature(header, payload)

func createSignature(header, payload string) string {
	data := fmt.Sprintf("%s.%s", header, payload)
	h := hmac.New(sha256.New, []byte(secretKey))
	h.Write([]byte(data))

	return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

The final token is a concatenation of the header, payload, and signature, separated by dots.

token := fmt.Sprintf("%s.%s.%s", header, payload, signature)

Validating Token

func validateToken(token string) (Claims, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return Claims{}, fmt.Errorf("invalid token format")
	}

	signature := createSignature(parts[0], parts[1])
	if signature != parts[2] {
		return Claims{}, fmt.Errorf("invalid token signature")
	}

	claimsJSON, err := base64.StdEncoding.DecodeString(parts[1])
	if err != nil {
		return Claims{}, err
	}

	var claims Claims
	err = json.Unmarshal(claimsJSON, &claims)
	if err != nil {
		return Claims{}, err
	}

	if claims.Expiry < time.Now().Unix() {
		return Claims{}, fmt.Errorf("token has expired")
	}

	return claims, nil
}

The validateToken function splits the token into its three parts, verifies the signature, and decodes the payload to extract the claims. It also checks if the token has expired.

First, the token is split into its header, payload, and signature parts.

parts := strings.Split(token, ".")

Then, we recreate the signature and compare it with the token's signature.

signature := createSignature(parts[0], parts[1])

if signature != parts[2] {
    return Claims{}, fmt.Errorf("invalid token signature")
}

If everything goes well, we then proceed with decoding the payload to extract its claims.

claimsJSON, err := base64.StdEncoding.DecodeString(parts[1])
var claims Claims

err = json.Unmarshal(claimsJSON, &claims)

Finally, we ensure the token hasnt't expired.

if claims.Expiry < time.Now().Unix() {
    return Claims{}, fmt.Errorf("token has expired")
}

Let's create some http handlers

Let's create some super simple handlers to demonstrate the functionality. Tokens should be sent via HTTP headers for security and convenience:

loginHandler

func loginHandler(w http.ResponseWriter, r *http.Request) {
	username := "user1"
	accessToken, refreshToken, err := createToken(username, 15*time.Minute)
	if err != nil {
		http.Error(w, "could not create tokens", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
	fmt.Fprintf(w, "Access Token: %s\n", accessToken)

	cookie := http.Cookie{
		Name:     "refresh_token",
		Value:    refreshToken,
		Expires:  time.Now().Add(24 * time.Hour),
		HttpOnly: true,
		Path:     "/",
	}
	http.SetCookie(w, &cookie)
}

Nothing fancy; we're just passing the username and the expiration time when we create a new token. In a real-world scenario, we would first ask the user to, for example, log in to our application. After the user successfully logs in, we would then create the token for their usage.

The important thing to notice here is that we're issuing an access token in the authorization header and we store the refresh token in an http-only cookie not only to avoid XSS attacks but to also use it later for whenever the access token expires.

refreshHandler

Extracts the refresh token from the cookie, validates it, and issues new access and refresh tokens.

func refreshHandler(w http.ResponseWriter, r *http.Request) {
	cookie, err := r.Cookie("refresh_token")
	if err != nil {
		http.Error(w, "no refresh token provided", http.StatusUnauthorized)
		return
	}

	refreshToken := cookie.Value
	claims, err := validateToken(refreshToken)
	if err != nil {
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}

	accessToken, _, err := createToken(claims.Username, 15*time.Second)
	if err != nil {
		http.Error(w, "could not create new access token", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
	fmt.Fprintf(w, "New Access Token: %s\n", accessToken)

	newCookie := http.Cookie{
		Name:     "refresh_token",
		Value:    refreshToken,
		Expires:  time.Now().Add(24 * time.Hour),
		HttpOnly: true,
		Path:     "/",
	}
	http.SetCookie(w, &newCookie)
}

protectedHandler

This is a protected route, meaning that if the user's access token is invalid, then they won't be able to see the page:

func protectedHandler(w http.ResponseWriter, r *http.Request) {
	authHeader := r.Header.Get("Authorization")

	if authHeader == "" {
		http.Error(w, "no token provided", http.StatusUnauthorized)
		return
	}

	token := strings.TrimPrefix(authHeader, "Bearer ")
	claims, err := validateToken(token)
	if err != nil {
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}

	fmt.Fprintf(w, "Protected! \n %+v", claims)
}

Running the application

We will first issue our two tokens by hitting the /login route:

$ curl localhost:8080/login

We would get back something like that:

Access Token: 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNzIwNzgzOTY1fQ==.JYyvYm3GxoLRRiXB7Ej7vKMi0Sb6C8G1pYy/jIvdmyw=

Next, accessing the /protected route should return the payload:

$ curl localhost:8080/protected -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNzIwNzgzOTY1fQ==.JYyvYm3GxoLRRiXB7Ej7vKMi0Sb6C8G1pYy/jIvdmyw="
Protected!
{Username:user1 Expiry:1720783965}

If our access token expires, we can obtain a new one either by logging out and logging in again or by hitting the /refresh route. The latter will use the refresh token stored in the cookie to repeat the process.

$ curl -b "refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNzIwODY5NDY1fQ==.8zaOrQlq780TrFy9UtfekSuT8VruGOPGMetQPt5d4CY=" localhost:8080/refresh
New Access Token: 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNzIwNzgzMDk5fQ==.pA9wR55iKh6kt7b5NJpeCn1wCPt8bykpZq3JXG/s3Vk=

Conclusion

We've built a simple version of this system from scratch using Go's standard library. However, many libraries exist that handle all this complex work for you. They offer more features and security options, making them a better choice for real-world applications.

Understanding these tokens is crucial for building secure apps, even if you use libraries to handle the heavy lifting. It helps you know what's going on behind the scenes and ensures your apps are safe and reliable for users.

Tags:
Back to Home