Please, secure your forms before switching to Next.js!

Matt Miller
8 min readFeb 19, 2024

I bought a car on Monday! 🎉 I found a great deal from a private seller, listed on one of the most popular online classifieds websites in the U.S. (who requested I keep them anonymous)— one that, right now, has well over 60,000 vehicles listed for sale among hundreds of thousands of other items.

Something peculiar and disturbing happened to me when I went to log into their website last week. I filled in my email address and password, pressed Return, and, to my horror, my credentials appeared in the URL:

/login?email=my%40email.address&password=MyPassword!

What the heck happened?!

I thanked myself for committing long ago to using a password manager, so thankfully I wasn’t horribly worried.

Meanwhile, the login web page reloaded with the new URL. I re-entered my credentials and gave it another try, and everything worked, so the first thing I did was reset my password on the website.

Next, I immediately went to contact the website’s support team. While waiting for their response, I decided to do some digging on my own.

What happened

It turns out, in this case, the problem was frighteningly easy to reproduce: all I had to do was open my browser settings and disable JavaScript! This immediately clued me into what had occurred: most likely, at the time I had visited the login page, something went temporarily wrong with either my Internet connection or their web server — and as a result, the JavaScript bundle that was meant to load alongside the page failed to.

It was easy from a cursory examination of the HTML source for the page that the website was being powered by Next.js. While React began as a client-side runtime framework, tools like Next.js bring React and JSX code to the server — there are many better articles to discuss the features and technical details of server-side rendering, so let’s just briefly go over the relevant difference from an “old-school” SPA and a server-side rendered React app:

In a traditional SPA, the JavaScript bundle includes and drives the entire app. You may have an index.html which is statically generated and served to every user. It usually contains minimal markup other than a <div id="app"></div> and imports the necessary code to render the app via script tags, e.g. <script src="./bundle.js"></script> .

In the unfortunate case that ./bundle.js failed to load, the user would likely end up with a blank white screen, since <div id="app"></div> would never get “hydrated” by React! Hopefully, the issue was ephemeral, and the user can simply refresh the page. This is exactly what would have happened in my case with the login page.

This WILL happen to you

As a developer, do not underestimate the importance of guarding for this specific scenario. It’s not good enough to use a noscript tag to catch users with JavaScript disabled — so many things can cause your bundle to not load. Here’s just a few, besides what happened to me:

  • A user is using an ad-blocking browser extension, firewall, or VPN which falsely identified your script as something it should block
  • Some code ended up in the bundle that is incompatible with a particular browser — it was a certain kind of syntax error, and none of the bundle was executed
  • You changed some part of your release process, and the bundle filename changed. The old bundle was removed, and some user was served a cached version of the HTML that tried to request a bundle that no longer exists
  • Your favorite CDN has 99.9% uptime — so even when all things are good, your odds are a thousand to one. Is that really good enough?

A blank white page is a suboptimal user experience, and certainly one you could code for if you cared — but it’s not a dangerous one. Putting a user’s credentials in the URL? That doesn’t just end up in your browser history, it ends up in a request log — and is potentially viewable by network admins, employers, people snooping on public wi-fi, etc.

Let’s talk server-side rendering

Let’s take a look at a typical login form coded in React:

import { useState } from "react";
import { useNavigate } from "react-router-dom";

import { login } from "@/api";

export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();

return (
<form
onSubmit={async (event) =>
{
event.preventDefault();
setSubmitting(true);

try {
await login(email, password);
navigate("/dashboard");
} catch {
alert("Your email/password is incorrect");
setPassword("");
setSubmitting(false);
}
}}
>
<label>
Email:
<input
type="email"
name="email"
value={email}
onChange={(event) =>
setEmail(event.target.value)}
/>
</label>
<label>
Password:
<input
type="password"
name="password"
value={password}
onChange={(event) =>
setPassword(event.target.value)}
/>
</label>
<button type="submit" disabled={submitting}>Log In</button>
</form>

);
}

As is the pattern of developing in React, we are effectively combining our view (JSX) with our view-binding logic (useState, onSubmit, value, onChange, disabled). Remember, after all, with the old-school SPA, our app rendering is sort of an all-or-nothing: we don’t really think here about what the behavior of such a component would be without the accompanying logic…

Now let’s circle back to Next.js and take a moment to visualize what the server will output from this JSX:

<div id="app">
<!-- ... -->
<form>
<label>
Email:
<input type="email" name="email" value="" />
</label>
<label>
Password:
<input type="password" name="password" value="" />
</label>
<button type="submit">Log In</button>
</form>
<!-- ... -->
</div>

<script src="./bundle.js"></script>

Do you see the problem here?

In the happy path scenario, this HTML is delivered to the user immediately, improving performance of the initial load of our app. Our app is first “rendered” on the server by Next.js, where only the static parts of our code can be extracted in order to spit out a HTML string to send to the user. Then, bundle.js loads and React “hydrates” our app, where it goes around attaching our event listeners, running our useEffect side effects, and so on.

But if bundle.js doesn’t load, we’re left with that barebones HTML form — not a blank page.

Now, I wouldn’t blame you if you don’t see the problem yet — especially if you started doing web development after the surge in popularity of various frontend development libraries and frameworks; you might be more accustomed at this point to using AJAX (fetch, axios) to interact with the server than traditional web forms.

How the web works without JavaScript

The HTML form element has been around for a long time, and before JavaScript enabled us to make network requests programmatically, forms were the primary method for sending data from the client back to the server. That’s why we always have to remember to call event.preventDefault() in our onSubmit handler — we’re making sure to tell the browser not to do what it does by default when the user submits.

When used in the traditional way, we usually pass some other attributes to the HTML form element: action, which is the URL to send the data and take the user to when they submit the form; and method, which is the HTTP method to use (except only GET and POST are supported!). The browser automatically encodes named input elements and sends them to the server.

If you’ve used the web pretty much at all, you’ll probably be familiar with the results of both of these form methods.

When you submit a form with POST and are taken to another web page, if you hit the Refresh button you’ll see a warning that refreshing will re-send the data from your form submission:

Browser “Confirm Form Resubmission” warning

A form submitted with POST sends a POST request to the specified URL and the form fields are added to the body of the HTTP request, just like when using a REST API endpoint; however, rather than having the ability to encode the data as JSON, it gets sent to the server in a simpler format (application/x-www-form-urlencoded) that looks much like the query string in your browser.

Forms with the GET method, on the other hand, append the form field data to the specified URL’s query string instead. You’ll see this on many sites where submitting after typing in some keywords into the search box will add ?q=your+search+keywords to the URL.

Now, when action is omitted from the form element, it simply defaults to submitting to the current page URL; and when the method attribute is omitted from the form element, it defaults to GET!

Hopefully now the problem is pretty clear. Our JSX source code for the LoginForm has a onSubmit function so at a glance, everything looks fine. And when testing the app, everything always works fine, because the JavaScript assets always load.

But when they don’t load, we are left with a static version of our app — it’s not just a harmlessly non-interactive blank screen; it’s potentially dangerously interactive.

Solutions

Thankfully their support team was quick to respond and I provided more details about what was going on and how to reproduce the issue with their login form by disabling JavaScript. Their awesome team had the problem fixed within hours.

Now, there are lots of ways to get around this particular problem:

Since the app is not actually utilizing the traditional behavior of the HTML form element submit event, it could just not use a form tag at all. This is what the classifieds website decided to do. This comes with caveats, however; semantic HTML is generally more accessible and has the benefit of applying frontend validation (e.g. required), automatically submitting when the user presses Return, etc. In this case, after the user fills out their credentials and clicks Log In, nothing happens at all.

Another option is disabling or hiding certain elements until the JavaScript loads. For example, if something only renders conditionally, and we only make that condition true as an effect, we can make it defer rendering until we are on the client-side (at the cost of a delay/wasted render):

import { useEffect, useState } from "react";
import { LoginForm } from "@/components/LoginForm";

export function LoginPage() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);

return isMounted ? <LoginForm /> : null;
}

But this effectively has us “opting out” of the benefits of server-side rendering in the first place.

My personal favorite approach is the one that I used before I started using frontend frameworks and used mostly vanilla JS and jQuery to add interactivity to static (or server-rendered) HTML, which is to set default values for action and method that will be read if the JS code doesn’t end up loading and invoking event.preventDefault():

export function LoginForm() {
// ...

return (
<form
action="javascript:;" // don't navigate away from page
method="post" // ensure if it is sent, it's not in the URL
onSubmit={(event) =>
{
event.preventDefault();
// ...
}}
>
{/* ... */}
</form>

);
}

I’m sure there are other solutions out there that might be more semantic, but this approach has served me well.

Conclusion

The official home of the React documentation now encourages developers to use a server-side framework like Next.js by default, and even discourages what was once their de-facto solution for getting started, create-react-app. What worries me about this is that there are thousands of React apps out there in production right now, and with how much the docs promote SSR, it might give developers a false sense of confidence that they can safely migrate their existing React app to Next.js, perform some end-to-end testing, and call it a day.

The fact is, whether you are new to React or have been using it for years, there are patterns which were perfectly valid when we were coding applications which required JavaScript to function, which are now sources of potentially critical security bugs — the kinds bugs that are frustratingly easy to miss and yet absolutely will occur in production.

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Matt Miller
Matt Miller

No responses yet

Write a response