Single-page Applications (SPAs)
are web apps that load a single HTML page. They update content dynamically with JavaScript, eliminating full-page reloads for a smoother experience. This provides smooth navigation and a more responsive feel. Because SPAs don't reload, traditional login methods need adjustments to stay secure, and this article will show you what to do.
Session Replay for Developers
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.
Happy debugging! Try using OpenReplay today.
Single-page Applications (SPAs) are web apps that load a single HTML page. They update content dynamically with JavaScript, eliminating full-page reloads for a smoother experience. This provides smooth navigation and a more responsive feel. Because SPAs don't reload, traditional login methods need adjustments to stay secure.
Secure authentication is crucial in SPAs. Compared to traditional applications, SPAs have a larger attack surface because they mostly rely on JavaScript. They are more open to security threats like Cross-Site Request Forgery (CSRF) and Cross-Site Scripting (XSS) attacks.
Tokens (access and refresh tokens) are mostly used to secure SPAs. These tokens are used to persist users' sessions in SPAs. These tokens might be open to CSRF and XSS attacks if not secured properly. We will look at the following in this article:
- Access Tokens: Small pieces of data used to verify a user’s identity within a web application. A web server typically sends these tokens to the client after a successful login. They are then included in subsequent requests to access protected resources.
- Refresh Tokens: Special tokens used to refresh access tokens. When an access token expires, SPAs can use a refresh token to get a new access token.
- CSRF (Cross-Site Request Forgery): A web attack that exploits a user's authenticated session on a trusted website. Imagine this: You're logged into your bank app (trusted website), and a scammer tricks you into clicking a link to a malicious website. The malicious website then sends a money transfer request to your bank. Your bank server sees this as a legitimate request because it includes the login cookies. This is exactly a CSRF attack.
- XSS (Cross-Site Scripting): An attack that tricks user's browsers into running malicious code. This code, often JavaScript, is injected into legitimate websites. When a user visits this compromised website, the malicious code runs in their browser. This code can steal user data or mess with the website's content.
- User persistence: A web application's ability to remember a user’s logged-in state even after they close the window or reload the page. This is very important to prevent re-logging for each action.
In this article, we will learn how to protect our SPAs from CSRF and XSS attacks while persisting user details. Here is a demo of what we will be building in this article:
As seen in the above GIF, our app requires a user to provide their name for login. The app persists the user login details after a successful authentication. As we will later see in the next sections, our app combines local storage and cookies to save users' tokens securely.
The complete code can be found on GitHub. Despite using React.js and Express.js in the repo and this article, the logic applies equally to every single-page application.
Challenges of Authentication in SPAs
CSRF attacks are a major concern for SPAs. In a typical CSRF attack, an attacker tricks the victim's browser into sending an unauthorized request to a trusted website (like your SPA) where the victim is logged in. Since the user's browser includes valid session cookies with the request, the server treats it as legitimate and processes it. This can lead to attackers performing unauthorized actions on the user's account within your SPA.
XSS attacks are another security concern for SPAs. Attackers can potentially inject malicious scripts into user input fields or other parts of the SPA's code. If these scripts are not properly sanitized, they can be executed by the user's browser, potentially stealing user data, hijacking sessions, or redirecting users to malicious websites.
In the subsequent sections, we will look at how to mitigate CSRF and XSS attacks in SPAs.
Getting Started
To code along, open your terminal and paste the command below to clone the repository:
git clone https://github.com/Olaleye-Blessing/secure_spa.git
The app has two folders, backend
and frontend
. The backend
is set up with the following packages:
-
express
: Express is a web framework for Node.js. We use it to create our server. -
cookieParser
: Cookie-parser is a package that helps us to work with cookies in our express app. -
cors
: Cors is also another package that enables Cross-origin resource sharing (CORS).
The backend
folder has an app.js
file with the following content:
import express from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
const app = express();
app.use(cookieParser());
app.use(express.json());
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.get("/", (req, res) => {
res.status(200).json({ status: "success" });
});
app.listen(3000, () => {
console.log(`Server listening at port 3000`);
});
Our app.js
file does the following:
-
express()
starts an app. -
app.use(cookieParser())
parses the cookie header. -
cors({ origin: 'http://localhost:5173', credentials: true })
allows our frontend to attach cookies when sending requests. -
app.get("/")
responds to requests to the root URL. -
app.listen(3000)
listens for new connections.
The frontend
folder is a basic React app created with Vite. Styling is done with TailwindCSS, and routing is handled with React Router. It contains the following files:
-
main.jsx
: It houses the whole app. -
app.jsx
: It imports the pages and nav component for navigation. -
components/nav.jsx
: It displays page links for easy navigation between pages. -
pages/*
: It has the home and login pages. -
pages/login/index.jsx
: It renders a login form. The login form only receives the user’s name for authentication:
import { useState } from "react";
export default function Login() {
const [name, setName] = useState("");
const onLogin = async (e) => {
e.preventDefault();
console.log({ name });
};
return (
<main>
<header className="text-center">
<h1>Login Page</h1>
</header>
<form
className="flex flex-col w-full max-w-xl mx-auto"
onSubmit={onLogin}
>
<div className="flex flex-col">
<label htmlFor="name" className="mb-1">
Name
</label>
<input
type="text"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="mt-4">
<button type="submit" className="block w-full">
Log In
</button>
</div>
</form>
</main>
);
}
Follow these steps to code along:
- Open your terminal and navigate to the
backend
folder. - Run the command below to start the server on port 3000:
npm run dev
- Open another terminal and navigate to the
frontend
folder. - Run the command below to start the React app on port 5173:
npm run dev
- Open your browser and navigate to http://localhost:5173/.
You should see a 2-page website as shown by the GIF below.
Generating Our Access and Refresh Tokens (Backend)
For demonstration purposes, we assume that any login credentials sent to the /login
route are valid. You should typically validate the payload data to ensure it matches a valid user in your database. We will use JSON Web Token (JWT) to generate our access and refresh tokens. Let’s see the code for this:
// ... previous code
import jwt from "jsonwebtoken";
const app = express();
// ... continue previous code
app.get("/", (req, res) => {
res.status(200).json({ status: "success" });
});
app.post("/login", (req, res) => {
const name = req.body.name;
const user = { name };
const accessToken = jwt.sign(user, "my-auth-token-secured-secret", {
expiresIn: "5m",
});
const refreshToken = jwt.sign(user, "my-refresh-token-secured-secret", {
expiresIn: "5d",
});
const fiveDays = 5 * 24 * 60 * 60 * 1000;
res.cookie("refreshTk", refreshToken, {
httpOnly: true,
secure: true,
expires: new Date(Date.now() + fiveDays),
path: "/refresh-token",
});
res.status(200).json({
status: "success",
data: {
accessToken,
user: { name },
},
});
});
// ... previous code
We create our /login
route using the post
method from Express. We then generate our tokens using the sign
method from JWT. In a real application, you must save token secrets in an environmental variable file.
We give our access token a short duration of 5m (5 minutes) because we want to make our access token as short-lived as possible. We then create our refresh token with a longer duration of 5d (5 days). We send our access token as part of our response body while we send the refresh token as a cookie.
You might be wondering, why is the access token sent as part of the response body instead of the cookies. This is to avoid a CSRF attack. Cookies are always sent on every request, including cross-domain requests. Imagine this scenario:
- I, as an attacker, send an email, lying to a user to log into your “secured” website because of suspicious activity.
- Then after some minutes, I trick them into clicking a malicious link that directs them to my website. Typically, my website will make a request to your “secured” website without the user’s knowledge.
- If your "secure" website uses cookies to log in users, my malicious request will include your login cookie.
Sending the access token as part of the response body prevents this attack.
We attach some attributes to our refresh for security reasons:
-
httpOnly
: This prevents the cookie from being accessible by JavaScript. For example, the Document.cookie will return an empty string. We use this attribute here because we don’t want any party, not even our frontend, to modify or access the refresh token. -
secure
: This allows the cookie to be only sent over an encrypted request, i.e. requests using the HTTPS (Hypertext Transfer Protocol Secure). Since our server is using HTTPS, an attacker trying to use the insecure protocol, HTTP, won’t be able to receive or send our cookie. -
expires
: This ensures our browser removes the cookie after our specified time. Here, the browser removes the refresh cookie after 5 days. -
path
: This ensures the cookie is not sent on every request. The cookie will only be sent when the path exists in the request URL. In our case, the refresh cookie will only be sent when we make a request to this path:/refresh-token
.
What if an attacker tricks the user into clicking their malicious link and sends a request with the refresh cookie? What happens? Our protected routes are always protected using the access token. This means that even though the refresh cookie is attached, access will be denied since they don’t provide the access token. OpenReplay has an article on protecting protected routes. Check the section, "Creating our Authentication Route".
Creating a New Access Token with Refresh Token
We must generate a new access token whenever a previous access token expires. Paste the code below the login route:
app.post("/refresh-token", (req, res) => {
const currentRefreshToken = req.cookies.refreshTk;
if (!currentRefreshToken)
return res.status(400).json({
status: "fail",
message: "Provide refresh token",
});
try {
const decodedToken = jwt.verify(
currentRefreshToken,
"my-refresh-token-secured-secret",
);
const name = decodedToken.name;
const user = { name };
const accessToken = jwt.sign(user, "my-auth-token-secured-secret", {
expiresIn: "5m",
});
const refreshToken = jwt.sign(user, "my-refresh-token-secured-secret", {
expiresIn: "5d",
});
const fiveDays = 5 * 24 * 60 * 60 * 1000;
res.cookie("refreshTk", refreshToken, {
httpOnly: true,
secure: true,
expires: new Date(Date.now() + fiveDays),
path: "/refresh-token",
});
res.status(200).json({
status: "success",
data: {
accessToken,
user: { name },
},
});
} catch (error) {
res.status(400).json({
status: "fail",
message: "Invalid refresh token",
});
}
});
We use the /refresh-token
route to generate new access tokens. We first made sure the user provided the previous refresh token given to them. Failure to have this token means they were never authenticated. We will send a status of 400 if this happens.
We then use the verify
method from JWT to make sure the old refresh token is valid:
- If it is valid, we repeat the same steps we used to log them in. That is, we generate new access and refresh tokens. These tokens will be used for subsequent requests.
- If invalid, our catch block will return a 400 error. The user will have to log in again.
When building your apps, you must add extra security when generating new tokens, like rotating refresh tokens.
This route ensures that users don’t need to log in each time their access token expires. Our frontend can easily ensure that the user stays logged in by sending a silent request to this route to refresh users’ tokens.
Persisting User Sessions Securely (Frontend)
In our SPA front end, we will get our initial tokens when a user logs in. Let’s create a login function to get our tokens first. Go to the frontend/src/pages/login/index.jsx
file and update the onLogin
function:
const onLogin = async (e) => {
e.preventDefault();
try {
const req = await fetch("http://localhost:3000/login", {
method: "POST",
body: JSON.stringify({ name }),
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
const res = await req.json();
console.log(res);
} catch (error) {
console.log("__ ERROR LOGGIN IN");
}
};
Our onLogin
function sends a request to the /login
route of our backend application. If successful, it gives the frontend access to the refresh cookie, access token, and user details. Our refresh cookie is saved by the browser automatically.
The browser saves our refresh cookie along with its attributes like path
, expires
, etc
We need to decide how to save our access token and user details. We have two options; we can save them in the:
- local storage
- app memory
Local Storage
Local storage persists data even after the web browser is closed. We can use this to our advantage like so:
localStorage.setItem("auth", JSON.stringify(res.data));
This allows us to read from the local storage when the user refreshes the page or revisits our website.
const auth = localStorage.getItem("auth");
There is a problem here. The local storage is open to XSS attacks. An attacker that “injects” some malicious code can grab the token and use it on behalf of your user.
App Memory
Saving in the app memory refers to storing information within the application itself. This can be done using JavaScript variables, objects, or state managers (like Zustand). Unlike local storage, this method is not open to XSS attacks.
Let’s go ahead and use the app memory. We first need to set up Zustand as our state manager. Navigate to your frontend
folder on a new terminal and run the command below:
npm i zustand
We then create a hook to save our login token and user details. Create /frontend/src/hooks/useAuth.js
file and paste the following:
import { create } from "zustand";
export const useAuth = create((set) => ({
user: null,
accessToken: null,
updateAccessToken: (accessToken) => set({ accessToken }),
updateUser: (user) => set({ user }),
}));
When our app initially loads, our useAuth
hook sets the default state for the user
and accessToken
to null. We provide two methods, updateAccessToken
and upateUser
to update user auth details.
We can now use this hook to save the user and token details after a user logs in. Go ahead and update the onLogin
function with the code below:
import { useState } from "react";
import { useAuth } from "../../hooks/useAuth";
export default function Login() {
const updateAccessToken = useAuth((state) => state.updateAccessToken);
const updateUser = useAuth((state) => state.updateUser);
// previous code
const onLogin = async (e) => {
e.preventDefault();
try {
// previous code
updateAccessToken(res.data.accessToken);
updateUser(res.data.user);
} catch (error) {
// previous code
}
};
// previous code
}
We first imported the hook into our file. We then pass the login detail into each function. This helps us to save the user details in our app memory.
We can prove this works by displaying our accessToken
in the navbar. Update your frontend/src/components/nav.jsx
with the following:
import { Link } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Navbar() {
const accessToken = useAuth((state) => state.accessToken);
return (
<nav className="border-b">
{/* previous code */}
<p className="px-4 pb-2">User Token: {accessToken}</p>
</nav>
);
}
We grab the access token from the previous hook we created. We then display it just under the page navigation. We would have an empty string when the user is unauthenticated, otherwise the token will be displayed.
However, the problem with this method is that the data saved in the app memory gets lost as soon as the browser is closed or the page is refreshed. This means that the login details will not be persisted. A logged-in user must log in again if they close our website or refresh the page.
This is a bad user experience as I can’t imagine re-logging every minute.
Persisting User Detail
To persist our login details, we will use the /refresh-token
route to get the user login details. We will do this immediately after our app launches. In React, we can wrap all our pages in a Higher-Order Component (HOC). Our HOC will make a request to the /refresh-token
route to get a new access token and user details. Since our refresh cookie has persisted, it will be sent with our request.
Create a /frontend/src/components/initialize-config.jsx
file and paste the following code:
import { useEffect, useState } from "react";
import { useAuth } from "../hooks/useAuth";
export default function InitializeConfig({ children }) {
const updateAccessToken = useAuth((state) => state.updateAccessToken);
const updateUser = useAuth((state) => state.updateUser);
const [loading, setLoading] = useState(true);
useEffect(function getUserLoginDetails() {
(async () => {
try {
setLoading(true);
const req = await fetch("http://localhost:3000/refresh-token", {
method: "POST",
credentials: "include",
});
const res = await req.json();
updateAccessToken(res.data.accessToken);
updateUser(res.data.user);
} catch (error) {
console.log("__ ERROR REFRESHING RESPONSE");
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return <div className="text-center text-6xl">Loading</div>;
return <div>{children}</div>;
}
Our HOC component, InitializeConfig
, shows a loading message when the website loads initially or when the user refreshes the page. When the component mounts, we use an Immediately Invoked Function (IIF) to send a request to the /refresh-token
route. The loading message shows while the request is pending. Whether the request is successful or not, the page content is displayed after the request resolves:
- If the request is successful, then we save the access token in the app’s memory.
- If not, nothing is saved. The user must log in to get new access and refresh tokens.
We need to update our /frontend/src/app.jsx
with the following content:
// previous code
import InitializeConfig from "./components/initialize-config";
function App() {
return (
<InitializeConfig>
<Router>{/* previous code */}</Router>
</InitializeConfig>
);
}
export default App;
We imported our InitializeConfig
component and used it to house our whole app. This ensures that no matter the page user is, we will always make a request to the /refresh-token
route if a user refreshes our app.
From the GIF, you noticed that we first refreshed the login page. The loading message is shown while getting the user access token and details. The login page is displayed after some seconds. Then we navigated to the home and repeated the same cycle.
Working with a Private Page
Now that we have the access token saved in the app’s memory and a way to persist the user’s session, let’s see how it works with a private page. By private page, we mean a page only accessible by a logged-in user. We will first create a HOC component that will protect the page. This HOC component will make sure only an authenticated user can access the page.
Create a /frontend/src/components/protected.jsx
file and paste the following code:
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Protected({ children }) {
const navigate = useNavigate();
const accessToken = useAuth((state) => state.accessToken);
const [authenticating, setAuthenticating] = useState(true);
useEffect(
function checkIfUserIsAuthenticated() {
if (!accessToken) return navigate("/login");
setAuthenticating(false);
},
[accessToken],
);
if (authenticating)
return <div className="text-center text-4xl">Authenticating</div>;
return <>{children}</>;
}
We set the initial value of authenticating
to true because we want to show a loading indicator when this component initially mounts. This will give us enough time to check if a user is authenticated or not.
For brevity, we only check if the access token exists:
- If it does, we remove the loading indicator and display the page content.
- If not, we redirect the user to the login page.
To test this new component, we will use of it on a new page called dashboard. The dashboard page will be accessible only by authenticated users. Create a frontend/src/pages/dashboard/index.jsx
file and paste the following:
import Protected from "../../components/protected";
function Page() {
return (
<main>
<header>
<h1>User Dashboard</h1>
</header>
</main>
);
}
export default function Dashboard() {
return (
<Protected>
<Page />
</Protected>
);
}
Our <Page />
component displays the title of the page, "User Dashboard". We then use the <Dashboard />
component to protect the page content. We do this to ensure that the logic in our <Page />
component only runs if the logic in the <Protected />
component allows it.
Let’s add the dashboard page to the navbar for easy navigation. Go ahead and update frontend/src/components/nav.jsx
:
// previous code...
export default function Navbar() {
const accessToken = useAuth((state) => state.accessToken);
return (
<nav className="border-b">
<ul className="flex items-center justify-center space-x-8 px-4 py-2">
{/* previous code */}
<li>
<Link to="/dashboard">Dashboard</Link>
</li>
</ul>
{/* previous code */}
</nav>
);
}
We first made sure the user was not logged in. When we input the dashboard URL in the address bar:
- Our
<InitializeConfig/>
HOC component tried to get a new access token. This failed because the user was never authenticated. - Then our
<Protected />
HOC component checked to see if a user is authenticated. It redirected to the login page since the user was never authenticated. - We logged in on the login page.
- We navigated to the dashboard page. The
<Protected />
HOC component ran again and allowed us to see the dashboard content. - When we refreshed the dashboard page, the flow restarted but this time around, we were allowed to see the dashboard content. This is because
<InitializeConfig/>
was able to get a new token and<Protected />
needed the token to protect the page.
Conclusion
This article covered securing user authentication in SPAs. We covered the importance of secure tokens, the vulnerabilities of SPAs (CSRF and XSS attacks), and how to persist user details.
On your server:
- Use JWTs to generate access and refresh tokens.
- Send access token as part of the response body to avoid CSRF attacks.
- Store refresh tokens as HttpOnly and Secure cookies for added security.
On your client:
- Use app memory to store access tokens to avoid XSS attacks.
- Get a user session when the app is refreshed.
Our tokens can further be protected by:
- Rotating refresh tokens.
- Blacklisting suspicious tokens.
Top comments (0)