skip to content
The when moon logo

Build a Go API with wallet authentication, JWT, Gin

Let's build a Golang API using Gin with wallet authentication, sign-in with Ethereum and JWT. It's fun.

Introduction

It’s becoming a common practice in the web3 world to let your users sign in with one click in their wallet. Handy for fast onboarding into your product.

We will walk through the different steps you have to take to make it come true. While the full code is available on github, for the sake of simplicity, we will focus mostly on why we are here and will not go in too much detail about setting up the project.

What do we use?

Let’s build

To achieve our goal we need 2 endpoints. First, /nonce/:address to generate a nonce for our wallet address, second, /signin to either sign-in or sign-up our user.

/nonce/:address

The nonce is a temporary string generated by our backend and included in the message the user sign to prevent malicious users to replicate our signature. It will be used during the signature verification in our second endpoint.

Let’s create our Gin handler

func Nonce(c *gin.Context) {
    // in my App I just have my db and redis
	app := c.MustGet("app").(*config.App)
	address := c.Param("address")

    // we check if it's a valid EVM like address
	if !evm.IsValidAddress(address) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid address format"})
		return
	}

    // we generate a nonce with SIWE
	nonce := siwe.GenerateNonce()

    // save the address annd nonce in redis
	err := app.Rdb.Set(ctx, address, nonce, 1*time.Minute).Err()
	if err != nil {
		log.Fatal(err)
	}

	c.JSON(http.StatusOK, gin.H{
		"nonce": nonce,
	})
}

Nothing very complicated here. After we generate our nonce using SIWE, we save it in Redis with the associated address. We don’t want to keep this nonce cached longer than necessary and set it to expire after 1min, which should be enough time to complete the sign-in.

Note that you can use whatever way to keep your nonce linked to the address. Just need to adapt to what fits for you.

Here is the code for IsValidAddress, you can use it with a common.Address from go-ethereum as well.

func IsValidAddress(iaddress interface{}) bool {
	re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$")
	switch v := iaddress.(type) {
	case string:
		return re.MatchString(v)
	case common.Address:
		return re.MatchString(v.Hex())
	default:
		return false
	}
}

Sweet! add this to your router

	auth := r.Group("/auth")
	{
		auth.GET("/nonce/:address", controllers.Nonce)
	}

We now have our first endpoint able to generate a beautiful nonce.

/signin

Now let’s do the interesting stuff.

For a detailed overview of what the message contains feel free to read this. It will make sense once we see the frontend side of the signature.

  1. Create your new Signin handler, the first step will be to parse our json body containing the message and the signature.
type signinParams struct {
	Message   string `json:"message" binding:"required"`
	Signature string `json:"signature" binding:"required"`
}

func Signin(c *gin.Context) {
	app := c.MustGet("app").(*config.App)

	var signinP signinParams
	if err := c.ShouldBindJSON(&signinP); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

}
  1. Use SIWE to parse the message and get the message struct that will help us verify our signature.

func Signin(c *gin.Context) {
	...
	// parse message to siwe
	siweMessage, err := siwe.ParseMessage(signinP.Message)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
    ...
}
  1. Remember our nonce? Time to get it back from the cache. We use the address from the message as our key.

func Signin(c *gin.Context) {
	...
	// get the nonce in cache for address
	addr := siweMessage.GetAddress().String()
	nonce, err := app.Rdb.Get(ctx, addr).Result()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
    ...
}
  1. Now we verify our signature! If we did a good job, we should be able to find our user address from the public key SIWE is returning.

func Signin(c *gin.Context) {
	...
	// you can apply domain restriction but we keeping it simple here
	domain := siweMessage.GetDomain()
	// verify signature
	publicKey, err := siweMessage.Verify(signinP.Signature, &domain, &nonce, nil)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
    // crypto package from go-ethereum
    addr = crypto.PubkeyToAddress(*publicKey).Hex()
    ...
}

Under the hood, Verify will also compare our nonce with the one from the signature.

OK! It’s User time !

At this point, we’re able to verify a user signature. When there are no errors, we want to do our user sign-in & sign-up logic.

Considering you’ve set up your db correctly and created a user table we can move forward. We use a simple User.

type User struct {
	gorm.Model
	Address string `gorm:"uniqueIndex" json:"address"`
}
  1. If our user exist already, we return it, if not we create it.
func Signin(c *gin.Context) {
	...
	var user model.User
	// if user exist we return it
	res := app.Db.Where("address = ?", addr).First(&user)
	if res.RowsAffected == 1 {
		c.JSON(http.StatusOK, gin.H{
			"user": user,
		})
		return
	}

	// if user not exist we create it
	user = model.User{Address: addr}
	res = app.Db.Create(&user)
	if res.Error != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return

	}

	c.JSON(http.StatusOK, gin.H{
		"user": user,
	})
}

🎉 Our users are now able to sign-up and sign-in. That’s nice. But now we need some JWT to keep them authenticated in our client and protect our routes.

JWT

First, if you’re not familiar with what a JWT is, go check the doc.

  1. Let’s create our GenerateJWT function.
type JWTClaim struct {
	Address string `json:"address"`
	jwt.RegisteredClaims
}

const JWT_EXPIRATION = 24 * 7 * time.Hour

func GenerateJWT(address string) (token string, err error) {

	var claims = JWTClaim{
		address,
		jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT_EXPIRATION)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}

	resToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	secret := os.Getenv("JWT_SECRET")
	signedToken, err := resToken.SignedString([]byte(secret))
	if err != nil {
		return "", err
	}
	return signedToken, nil
}

The one thing, in particular, I want to keep in my JWTClaim is my user address. It will be easy to retrieve later when our users are sending requests with their token.

  1. Now we want to create the ValidateToken function.
func ValidateToken(signedToken string) (addres string, err error) {
	token, err := jwt.ParseWithClaims(signedToken, &JWTClaim{}, func(t *jwt.Token) (interface{}, error) { return []byte(os.Getenv("JWT_SECRET")), nil })
	if err != nil {
		return "", err
	}
	claims, ok := token.Claims.(*JWTClaim)
	if !ok {
		return "", errors.New("error parsing claims")
	}
	if claims.ExpiresAt.Unix() < time.Now().Local().Unix() {
		return "", errors.New("token expired")
	}

	return claims.Address, nil
}

Here we just parse our token and verify its validity or if it has expired. You can add any extra checks that fit your needs. We keep it simple here. Notice that we return the address extracted from the token so we can use it in our next step.

Back to /signin

We now have everything we need to complete our Signin handler.

The end of our function should look like that

	addr = crypto.PubkeyToAddress(*publicKey).Hex()

	token, err := auth.GenerateJWT(addr)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var user model.User
	// if user exist we return it
	res := app.Db.Where("address = ?", addr).First(&user)
	if res.RowsAffected == 1 {
		c.JSON(http.StatusOK, gin.H{
			"user": user,
			"jwt":  token,
		})
		return
	}

	// if user not exist we create it
	user = model.User{Address: addr}
	res = app.Db.Create(&user)
	if res.Error != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return

	}

	c.JSON(http.StatusOK, gin.H{
		"user": user,
		"jwt":  token,
	})

We just generate a token and return it along with our user. And voila. For now, it’s still useless, we need to add our last piece.

Auth middleware

If we want to protect our routes we need to add a middleware that checks the JWT validity for every request. We’re lucky it’s easy!

func Auth() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.GetHeader("Authorization")
		if token == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "jwt missing"})
			return
		}
		address, err := auth.ValidateToken(token)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
			return
		}
		c.Set("address", address)
		c.Next()
	}
}

Easy indeed! We also add the user address in our Gin context so we can retrieve it in our handler.

Getting our user

Let’s use our brand new middleware.

	users := r.Group("/users").Use(middlewares.Auth())
	{
		users.GET("/me", controllers.GetUser)
	}
func GetUser(c *gin.Context) {
	app := c.MustGet("app").(*config.App)
	address := c.MustGet("address")

	var user model.User
	app.Db.Where("address = ?", address).First(&user)
	c.JSON(http.StatusOK, user)
}

If you GET /users/me with a valid JWT, you will get your user. And our journey comes to an end.

Find the full code here

Bonus Frontend

Now if you want to use our API from your NextJs frontend (or whatever else you use), still using SIWE, it is relatively easy.

"use client"

import { useContext, useEffect } from "react"
import { SiweMessage } from "siwe"
import { useAccount, useNetwork, useSignMessage } from "wagmi"

import { challenge, fetchUser, login } from "@/lib/api/api"
import { UserContext } from "@/lib/context/UserContext"
import { Button } from "./ui/button"

export const LoginButton = () => {
  const account = useAccount()
  const { chain } = useNetwork()
  const { signMessageAsync } = useSignMessage()

  const { setUser } = useContext(UserContext)

  const buildSiweMessage = (
    nonce: string,
    address: string,
    chainId: number
  ) => {
    return new SiweMessage({
      domain: window.location.host,
      address: address,
      statement: "Sign in with Ethereum toc the app.",
      uri: window.location.origin,
      version: "1",
      chainId,
      nonce: nonce,
    })
  }

  function loginClicked() {
    challenge(account.address!)
      .then(async (res) => {
        const message = buildSiweMessage(
          res.nonce!,
          account.address!,
          chain?.id!
        )
        const m = message.prepareMessage()
        const signature = await signMessageAsync({
          message: m,
        })
        return [m, signature]
      })
      .then((res) => {
        const message = res[0] as string
        const signature = res[1] as string
        return login(message, signature)
      })
      .then((res) => {
        setUser!({ address: res.user.address })
        return fetchUser(res.jwt)
      })
      .catch((err) => {
        console.log(err)
      })
  }

  return (
    <Button
      onClick={() => {
        loginClicked()
      }}
    >
      Login
    </Button>
  )
}
const BASE_URL = process.env.NEXT_PUBLIC_API_URL!

export const challenge = (address: string) => {
  return fetch(`${BASE_URL}/auth/nonce/${address}`, {
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
  }).then((res) => {
    return res.json()
  })
}

export const login = (message: string, signature: string) => {
  return fetch(`${BASE_URL}/auth/signin`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      message: message,
      signature: signature,
    }),
  }).then((res) => {
    return res.json()
  })
}

export const fetchUser = (token: string) => {
  return fetch(`${BASE_URL}/users/me`, {
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: token,
    },
  }).then((res) => {
    return res.json()
  })
}

That’s it for today. Thanks for reading till the end. Feel free to comment with anything on your mind and suggestions for improvements.