Avoid XSS and CSRF Attacks in JWT (React + Golang): A Tutorial

Written by nitoge | Published 2022/03/20
Tech Story Tags: golang | jwt | react | typesc | golang-xss-attack-avoid | golang-csrf-attack-avoid | programming | json-web-token-attack-react

TLDRThere is 2 way to store JWT in frontend: a. Store it in localStorage b. Store it in Cookie For (a.), It is CSRF safe but is vulnerable to XSS. While (b.) It is XSS safe but is vulnerable for CSRF.via the TL;DR App

Cross-site scripting(XSS) and Cross-Site Request Forgery(CSRF) are likely to occur if a JSON Web Token(JWT) is not properly stored in the browser.

In this article, I will share how we can avoid those 2 attacks when using JWT in our web application.

I value your time, so I will start off with how to accomplish this in a summary.

  1. The user fills up the login form and hit the submit button from the Frontend.

  2. Once the user is authenticated from the Backend, a JWT access_token will be sent and a refresh_token will be set in an HTTP-Only cookie.

  3. Frontend stores the access_token in-memory

    var access_token = data.access_token; 
    
  4. When the user refreshes the page, the access_token stored in memory will be gone. But we can still retrieve the access_token by making an API call via the refresh_token stored in HTTP-Only cookie (Step 2). Then FE will have the access_token again, which is used to communicate with Backend.

I will walk you through the tutorial from the Backend and then the Frontend. I hope this will be easy to for you to understand the article.

Backend

I will use echo as my HTTP routing framework in this entire tutorial.

1. Create a routing framework

Start your backend router with AllowCredentials: true which sets Access-Control-Allow-Credentials in the response header. This configuration tells browsers whether to expose all responses to the frontend JavaScript code when the request's credentials mode (Request.credentials) is include.

e := echo.New()
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
   AllowCredentials: true,
}))

2. Create Login API

In this Login API, you should perform authentication based on the credentials sent by the user before you proceed.

Once the user is authenticated, the Backend should generate an access_token and a refresh_token to send back to the Frontend in the response body, along with an HTTP-Only Cookie.

type User struct {
   Name string `json:"name"`
}

func Login(c echo.Context) error {
   u := new(User)
   if err := c.Bind(u); err !=nil {
      log.Errorf("error in bind: %s", err)
      return err
   }
   accessToken, exp, err := generateAccessToken(u)
   if err != nil {
      return err
   }

   refreshToken, exp, err := generateRefreshToken(u)
   if err != nil {
      return err
   }
   setTokenCookie(refreshTokenCookieName, refreshToken, exp, c)

   return c.JSON(http.StatusOK, echo.Map{
      "message": "login successful",
      "access_token": accessToken,
   })
}

3. Create Refresh API

When the user refreshes the page in the browser or the token in the Frontend has expired, the Frontend can call this API to retrieve a new set of access_token and refresh_token.

func RefreshToken(c echo.Context) error {
   cookie, err := c.Cookie(refreshTokenCookieName)
   if err != nil {
      if err == http.ErrNoCookie {
         return c.NoContent(http.StatusNoContent)
      }
      log.Errorf("err is : %s", err)
      return err
   }

   token, err := jwt.ParseWithClaims(cookie.Value, &jwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
      return []byte(GetRefreshJWTSecret()), nil
   })
   if err != nil {
      log.Errorf("err is : %s", err)
      return err
   }

   if !token.Valid {
      return errors.New("error")
   }

   claims := token.Claims.(*jwtCustomClaims)
   log.Infof("claims.Name : %+v", claims.Name)

   u := &User{Name: claims.Name}

   accessToken, exp, err := generateAccessToken(u)
   if err != nil {
      return err
   }

   refreshToken, exp, err := generateRefreshToken(u)
   if err != nil {
      return err
   }
   setTokenCookie(refreshTokenCookieName, refreshToken, exp, c)

   return c.JSON(http.StatusOK, echo.Map{
      "access_token": accessToken,
   })
}

4. Create Get User API

The frontend can call this API to get the user information based on the access_token provided by the Login API or Refresh API.

func UserAPI(ctx echo.Context) error {
   accessToken := ctx.Request().Header.Get("Authorization")

   token, err := jwt.ParseWithClaims(accessToken, &jwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
      return []byte(jwtSecretKey), nil
   })
   if err != nil {
      log.Errorf("err is : %s", err)
      return err
   }

   claims := token.Claims.(*jwtCustomClaims)
   log.Infof("claims.Name : %+v", claims.Name)

   if !token.Valid {
      return ctx.JSON(http.StatusUnauthorized, echo.Map{
         "message": "Unauthorized",
      })
   }
   return ctx.JSON(http.StatusOK, echo.Map{
      "name": claims.Name,
   })
}

The above-mentioned APIs (Login, Refresh and Get User API) are the only APIs required for Backend.

The Frontend components will be introduced in the following parts.

Frontend

1. Refresh and Get User API

During page load, we will make an API call to the Backend refresh API with credentials: 'include'. The HTTP-Only cookie will be sent to the Backend from the browser. If a valid refresh_token exists in the HTTP-Only cookie, the Backend will respond with a new access_token to the Frontend, which is used to make an API call to retrieve the user information from the Backend.

// Use this token as Authorization token to communicate with the other API.
  const [token ,setToken] = React.useState<string>('');
  // Retrieve the user information from the AccessToken.
  const [username ,setUsername] = React.useState<string>('');

  useEffect(() => {
    async function refresh() {
      // You can await here
      const resp = await fetch('http://localhost:8080/refresh', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include',
      });
      console.log("status is " + resp.status);
      if (resp.status === 204) {
        console.log("exit")
        return
      }

      const d = await resp.json();
      if(resp.ok) {
        console.log('Login Success:', d);
        setToken(d.access_token);
        user(d.access_token);
      }
    }
    refresh();
  }, []);

  const user = async (token: string) => {
    const resp = await fetch('http://localhost:8080/user', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `${token}`,
      },
      credentials: 'include',
    });

    const d = await resp.json();
    if(resp.ok) {
      console.log('User Success:', d);
      setUsername(d.name);
    }
  }

2. OnFinish function

This function will be triggered once the user submits the login information from the browser. If the user credential is valid, the Backend will respond with a new access_token and refresh_token in the response body and HTTP-Only cookie respectively.

  const onFinish = async (values: any) => {
    const resp = await fetch('http://localhost:8080/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({'name': values.username}),
      credentials: 'include',
    });
    const {data, errors} = await resp.json();
    if (resp.ok) {
      setToken(data.access_token);
      setUsername(values.username);
    } else {
      console.error(errors);
    }
  };

3. Render function

This UI contains only one Username input. When the user hits the submit button, onFinish will be triggered with a parameter of the value in the input.

return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Welcome { username }.
        </p>
        <Form
          name="basic"
          labelCol={{ span: 8 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ remember: true }}
          onFinish={onFinish}
          autoComplete="off"
        >
          <Form.Item
            label="Username"
            name="username"
            rules={[{ required: true, message: 'Please input your username!' }]}
          >
            <Input />
          </Form.Item>
          
          <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
            <Button type="primary" htmlType="submit">
              Login
            </Button>
          </Form.Item>
        </Form>
      </header>
    </div>
  );

Conclusion

As you can see, to avoid the security issues mentioned in the title, the safest way is to store the JWT token in memory. There is nothing to be worried about for the refresh_token in the HTTP-Only cookie, because the attacker can’t use the refresh_token to perform any action from their website.

You can download the full source code here.

That‘s all for my sharing, I hope you will like it.


Written by nitoge | Stop talking
Published by HackerNoon on 2022/03/20