Courses Learn Blog Contact
Get Started →
Lesson 17

Day 17: Security for Frontend Developers

XSS demonstrated properly, why nothing in frontend JavaScript is secret, browser validation as courtesy rather than defence, and the difference between 401 and 403.

Webbo3 Level 2: JavaScript Development 12 min read Free

Webbo3 Level 2 · JavaScript Development · Day 17 of 20

Security for Frontend Developers

Not a security course. The specific mistakes a junior is expected not to make, and why each one matters.

By the end of today you can

  • Explain XSS and stop it in your own code
  • Know exactly which values are safe to put in frontend JavaScript
  • Treat browser validation as a courtesy and never as a defence
  • Say what authentication and authorisation each mean
  • Handle a secret you have already committed

You are not going to become a security engineer today. You are going to stop making five specific mistakes that appear in almost every beginner project.

Each of them has caused real incidents at real companies. Stopping all five is a realistic and genuinely useful goal for one day.

1. The One Rule Underneath All of It

Definition

Untrusted input is any value your code did not create itself: form input, URL parameters, API responses, anything in localStorage. All of it can be changed by whoever is sitting at the browser, including somebody who is not the user you imagined.

Real-Life Example

A bank teller does not pay out because a note says "please give the bearer ₦500,000". The note came from outside the bank, so it is checked, however official it looks.

Never Trust Anything That Came From Outside Your Code

Everything else today is this sentence applied to one specific place.

The address bar is outside your code. Here is a welcome page that reads the visitor's name from the URL. new URLSearchParams(location.search) reads the part of the address after the ?, and .get('name') gets one value from it.

What this code doesWe want to see what happens when somebody sends a visitor a link whose name parameter is HTML instead of a name.
index.html
1
<h2 id="welcome"></h2>
app.js
1
2
3
4
5
6
// Read ?name=... from the address
const params = new URLSearchParams(location.search);
const name = params.get('name');

// The mistake: the name goes in as HTML
document.querySelector('#welcome').innerHTML = `Welcome, ${name}`;
BROWSERindex.html?name= · opened from a link somebody sent
CONSOLE   BROWSER DEVTOOLS
Failed to load resource: net::ERR_CONNECTION_REFUSED
code from the link ran

Nobody typed anything. Clicking a link was enough to run code on your page.

Picture This

Picture the bank teller holding a note from a stranger. However official it looks, it gets checked before anything is paid out.

2. XSS: The One That Will Actually Happen to You

Definition

Cross-site scripting (XSS) is putting untrusted text somewhere the browser treats it as code. You met it on Day 4. Today you see exactly why it is serious.

Real-Life Example

A shop notice board where anyone can pin a note. XSS is a note that, when a customer reads it, quietly takes the keys out of their pocket. The reader did nothing except read.

Why It Is Serious

The injected code runs with every privilege of whoever is looking at the page. It can read what your page can read, including a login token in localStorage, and send requests as that person. On an admin's screen it can do anything an admin can do.

What this code doesWe want to watch a comment steal a login token. The page keeps the reader's token in localStorage, and an attacker posts a comment that is really an image tag.
index.html
1
2
3
4
5
<ul id="comments"></ul>
<form id="add">
  <input id="comment" placeholder="Write a comment">
  <button>Post</button>
</form>
app.js
1
2
3
4
5
6
7
8
9
// The logged-in reader's token, kept in localStorage
localStorage.setItem('token', 'reader-token-7f3a91');

// The mistake: each comment goes in as HTML
document.querySelector('#add').addEventListener('submit', (event) => {
  event.preventDefault();
  const comment = document.querySelector('#comment').value;
  document.querySelector('#comments').innerHTML += `<li>${comment}</li>`;
});
BROWSERindex.html · after the attacker's comment was posted
CONSOLE   BROWSER DEVTOOLS
Failed to load resource: net::ERR_CONNECTION_REFUSED
stolen: reader-token-7f3a91

The broken image is all anyone sees. A real attacker replaces console.log with a fetch to their own server, and every reader's token arrives there.

The Fix, and It Is One Word

textContent puts characters on the page as characters. The browser never reads them as HTML, so nothing in them can run.

What this code doesWe want the same hostile comment put into two boxes, one with innerHTML and one with textContent, so the difference is visible side by side.
index.html
1
2
<p>innerHTML: <span id="as-html"></span></p>
<p>textContent: <span id="as-text"></span></p>
app.js
1
2
3
4
5
6
7
const hostile = '<img src=x onerror="console.log(\'innerHTML ran it\')">';

// Parsed as HTML: a real image with a real handler
document.querySelector('#as-html').innerHTML = hostile;

// Kept as text: the same characters, shown and never run
document.querySelector('#as-text').textContent = hostile;
BROWSERindex.html · the same string, two ways
CONSOLE   BROWSER DEVTOOLS
Failed to load resource: net::ERR_CONNECTION_REFUSED
innerHTML ran it

Types: Where Untrusted Text Can Go

SinkSafe with user inputNote
textContentYesUse this by default
innerHTMLNoOnly for markup you built yourself
insertAdjacentHTMLNoSame risk as innerHTML
setAttribute("href", x)NoA URL runs on click
eval, new FunctionNeverThere is no safe version
document.writeNeverObsolete as well as unsafe

The href row surprises people. A link's address can start with javascript:, and clicking it runs the rest as code. Here a student profile lets the user set their website.

What this code doesWe want to prove a user-supplied link address can run code, by clicking a "website" link whose address starts with .
index.html
1
<p>Website: <a id="site">visit</a></p>
app.js
1
2
3
4
5
// The address the user saved as their website
const website = "javascript:console.log('code ran from a link address')";

// The mistake: any address is accepted
document.querySelector('#site').setAttribute('href', website);
BROWSERindex.html · after clicking the link
CONSOLE   BROWSER DEVTOOLS
code ran from a link address

The fix is to accept only web addresses. new URL(text) reads an address into its parts and throws if it is not a full address; its .protocol is the part before the //, such as 'https:'.

What this code doesWe want a check that lets real websites through and refuses addresses and rubbish, before anything reaches href.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Return the address only if it is a normal web link, otherwise null
function safeLink(text) {
  try {
    const url = new URL(text);
    if (url.protocol === 'https:' || url.protocol === 'http:') return url.href;
    return null;
  } catch (error) {
    return null; // not a full address at all
  }
}

console.log(safeLink('https://chidinma.dev'));
console.log(safeLink("javascript:alert('hi')"));
console.log(safeLink('not a link'));
OUTPUT   TERMINAL
➜ webbo3 node app.js
https://chidinma.dev/
null
null
When You Genuinely Need HTML From a User

A comment box that allows bold and links is a real requirement. The answer is never your own regular expression: you will miss something, everybody does. Use a maintained sanitiser such as DOMPurify, and let it decide what survives.

Try This

Add a record to your Day 15 project whose name is <img src=x onerror="alert(1)">. If a popup appears, you have an XSS hole. Find the innerHTML it went through and switch it to textContent.

Picture This

Picture the notice board note that picks the reader's pocket. textContent pins every note behind glass: readable, and unable to reach anybody.

3. Anything in Your Frontend Is Public

Definition

Every line of JavaScript your page runs is downloaded to the visitor's computer, where anyone can read it in DevTools. There is no such thing as a secret in browser JavaScript. Minifying does not help. Obfuscating does not help.

Real-Life Example

Writing your ATM PIN on the back of your card in tiny handwriting. Smaller writing does not make it a secret; it just takes a stranger a second longer to read.

Types: What May Go in the Frontend

ValueFrontend?Because
A publishable key, such as a Stripe publishable keyYesDesigned to be public; it cannot do damage alone
A map key "restricted by domain"UsuallyPublic, but limited to your domain. Set the restriction
A secret API keyNoBills you, reads data, cannot be undone
A database passwordNoNever leaves a server
An admin tokenNoFull access to everything

Secrets for tools that run on your machine go in a file called .env, which git is told to ignore with a .gitignore file. A .env.example with the names but no values is committed, so a teammate knows what they need.

What this code doesWe want to prove that .gitignore keeps the real .env out of the commit while the example file and the ignore rule go in.
terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
git init -q project && cd project

# The real key: stays on this computer
echo 'WEATHER_API_KEY=abc123realkey' > .env

# The template: committed, so teammates know the name
echo 'WEATHER_API_KEY=' > .env.example

# Tell git to ignore .env
echo '.env' > .gitignore

git add .
git status --short
OUTPUT   TERMINAL
A  .env.example
A  .gitignore

If You Have Already Committed a Key

Deleting it in the next commit does nothing, because git keeps every earlier version. git show HEAD~1:app.js prints app.js as it was one commit ago.

What this code doesWe want to see whether deleting a key in a later commit removes it, by reading the file back out of the history.
terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
git init -q site && cd site

# Commit 1: the key goes in by mistake
echo 'const KEY = "sk_live_abc123";' > app.js
git add app.js && git commit -qm "Add weather widget"

# Commit 2: the key is "removed"
echo 'const KEY = "";' > app.js
git commit -qam "Remove key"

echo '--- app.js now:'
cat app.js
echo '--- app.js one commit ago:'
git show HEAD~1:app.js
OUTPUT   TERMINAL
--- app.js now:
const KEY = "";
--- app.js one commit ago:
const KEY = "sk_live_abc123";
Rotate the Key Immediately

Public repositories are scanned automatically within minutes. Go to the provider, revoke the old key and issue a new one. Rewriting history is optional; revoking is not, and it is the only step that actually helps.

The honest architecture: a key that must stay secret lives on a server you control, your frontend calls your endpoint, and your endpoint calls the third party. That is Level 3. Until then, prefer APIs that need no key.

Picture This

Picture the PIN on the back of the card. If anyone has seen the card, you do not rub the number out: you change the PIN.

4. Browser Validation Is a Courtesy

Definition

Client-side validation is checking input in the browser, as on Day 13. It runs on the user's computer, under the user's control, so it helps honest users and stops nobody else.

Real-Life Example

A "please queue here" sign. It helps everyone who wants to queue properly, and it has never once stopped someone who does not.

Types: Three Ways Round It

  • Open DevTools, delete the required or min attribute, submit.
  • Change a hidden input, such as a price.
  • Ignore the page entirely and send the request straight from the Console.
What this code doesWe want to do what any user can: remove the min attribute in DevTools, then order at a price of minus ₦5,000.
index.html
1
2
3
4
5
6
<form id="order">
  <label for="price">Price (₦)</label>
  <input id="price" type="number" min="1" required>
  <button>Place order</button>
</form>
<p id="sent"></p>
app.js
1
2
3
4
5
6
// The browser checks min="1" and required before this runs
document.querySelector('#order').addEventListener('submit', (event) => {
  event.preventDefault();
  const price = Number(document.querySelector('#price').value);
  document.querySelector('#sent').textContent = `Order sent with price ₦${price}`;
});
BROWSERindex.html · after removing min in DevTools and submitting
The Rule, Stated Once, for the Rest of Your Career

Validate in the browser so honest users get fast, kind feedback. Validate on the server because that is the only check that cannot be removed. Any rule that matters, a price, a permission, a limit, is enforced on the server or it is not enforced.

Picture This

Picture the queue sign: kind to the people who read it, and invisible to the one who does not.

5. Authentication and Authorisation

Definition

Authentication is proving who you are. Authorisation is deciding what you are allowed to do. They are different checks, and a site needs both.

Real-Life Example

At an office building, the security desk checks your ID: that is authentication. Your access card opens the third floor and not the server room: that is authorisation.

AuthenticationAuthorisation
QuestionWho are you?What are you allowed to do?
ProofPassword, token, one-time codeRoles and permissions
Status code401403
Failure looks likeAnyone can log in as anyoneA student opens the admin page

Hiding a Button Is Not Authorisation

This page hides the Delete button from a student. The practice API behind it, like a badly built real one, deletes for anyone who asks. The student copies the request from the Network tab and sends it from the Console.

What this code doesWe want to see whether hiding the Delete button protects anything, by sending the delete request from the Console as a student.
index.html
1
2
<p>Logged in as: <b id="who"></b></p>
<button id="delete-user">Delete user 42</button>
app.js
1
2
3
4
5
6
7
const user = { name: 'Musa', role: 'student' };
document.querySelector('#who').textContent = `${user.name} (${user.role})`;

// Hide the admin button from students
if (user.role !== 'admin') {
  document.querySelector('#delete-user').hidden = true;
}
BROWSERindex.html · after the student sent the request from the Console
CONSOLE   BROWSER DEVTOOLS
server replied: {"deleted":42}

The button was hidden; the capability was not. Hiding controls is user experience. Checking the permission on the server, and answering 403, is security.

Where a Token Lives

localStorage is readable by any JavaScript on the page, as the comment in Section 2 proved, so one XSS hole hands over every logged-in session. The safer arrangement is an httpOnly cookie, which JavaScript cannot read at all. That is a server decision and therefore Level 3, but know the trade-off now, because every tutorial you read will offer you the localStorage version.

Picture This

Picture the office: the desk checks your ID, the card decides which doors open, and taking the sign off the server-room door does not lock it.

6. Two More Worth Ten Minutes

Dependencies

Every package you install runs with your privileges and brings its own dependencies. Real attacks have come through a package that a package that your package depends on.

What this code doesWe want the three commands that tell you what your project has installed and whether any of it is known to be dangerous.
terminal
1
2
3
npm audit            # what is known to be vulnerable
npm audit fix        # fix what can be fixed safely
npm ls some-package  # why is this even installed
NOTE   
Run these in a folder that has a package.json.
  • Check the weekly downloads and the last publish date before installing anything.
  • Read the name twice. Typosquatted packages differ from a popular one by one character, on purpose.
  • Ask whether the browser already does it. Day 12 was largely that question.

HTTPS

Over plain http, anyone between your user and your server can read and change everything, including injecting scripts into your page. https is also required for geolocation, the clipboard, service workers and notifications. GitHub Pages, Netlify and Vercel all give you https for nothing, so there is no remaining excuse.

Do This Now

  1. Add <img src=x onerror="alert(1)"> as a record in your Day 15 project. If you get a popup, find the hole and fix it with textContent.
  2. Search your whole project for innerHTML. For every hit, write down where that string came from. Any that traces back to a user gets changed.
  3. Open your deployed project, delete a required attribute in DevTools and submit. Watch your own validation disappear.
  4. Run npm audit if you have a package.json. Read one advisory in full.
  5. Check every repository you pushed this month for a committed key. If you find one, rotate it today.

Checkpoint

Q1. Why is XSS worse than "somebody can make my page look odd"?

The injected script runs with the privileges of whoever is viewing the page. It can read their token and storage, make requests as them, and on an administrator's screen do anything an administrator can do.

Q2. You committed an API key and deleted it in the next commit. Safe?

No. It is in the history, as git show HEAD~1 proved, and public repositories are scanned within minutes. Rotate the key at the provider.

Q3. Your form checks the price is above zero. Is the price safe?

No. The check runs in a browser the user controls, and removing min took one line. Prices are validated on the server or not at all.

Q4. The Delete button is hidden for non-admins. Is deletion protected?

No. Hiding a control is user experience. If the endpoint accepts the request from anyone, anyone who opens the Network tab can send it.

Tick Before You Move On

  • ☐ I tested my own project with a hostile string and it did not execute
  • ☐ Every innerHTML in my code is markup I built, not user text
  • ☐ Every user-supplied link goes through a check like safeLink()
  • ☐ No key of any kind is in my committed code, and .env is ignored
  • ☐ I can say what 401 and 403 each mean without looking it up
  • ☐ My deployed project is served over https

Quick Recap

Never trust anything that came from outside your code. textContent for user text; a real sanitiser when you genuinely need HTML. Anything in frontend JavaScript is public, so rotate a leaked key immediately. Browser validation is kindness; server validation is the defence. Hiding a button is not authorisation.

Final Scenario

Before your project goes live, you spend fifteen minutes attacking it yourself.

You post a comment that is an image tag and see plain text. You set your website to a address and the check refuses it. You search the history for "key" and find nothing. You remove the min attribute and note that the server must check the price too.

Fifteen minutes, five mistakes ruled out, and a project you can hand to anyone.

Tomorrow, Day 18: getting it off your laptop and onto a URL you can send to somebody.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.