Frontend Authentication, Explained Simply
How login actually works on the web, in plain English. Sessions and cookies, JWTs, OAuth and 'Login with Google', refresh tokens, and where to store any of it without getting hacked. Built up from zero with diagrams and code.
Published · by Frontend Masters India
You build a login form. The user types an email and password, clicks the button, and lands on their dashboard. They click to another page. Now the server has completely forgotten who they are. They have to log in again. That is the problem authentication solves, and the way the web solves it is genuinely clever once you see it. This article builds the whole picture from zero: how login works, what a JWT really is, what "Login with Google" is doing behind the scenes, and where to keep all of this so you do not get hacked.
First, two words people mix up
Authentication is "who are you?" Authorization is "what are you allowed to do?"
You prove who you are at the airport check-in by showing your passport. That is authentication. Then your boarding pass says you can sit in seat 14C but not walk into the cockpit. That is authorization. Login is authentication. Everything after it, deciding what you can see and touch, is authorization. People shorten both to "auth," which is why it gets confusing.
This article is mostly about authentication: proving who you are, and staying proven as you click around.
The real problem: the web has no memory
Here is the thing nobody tells you up front. The web was built to forget you.
Every time your browser asks a server for something (a page, some data, an image), that request arrives cold. The server has no idea it just talked to you one second ago. Each request is a stranger walking up to a counter. This is called being "stateless," which is a fancy way of saying "no memory between visits."
request 1: "give me my profile" ─▶ server: "who are you? no idea."
request 2: "give me my orders" ─▶ server: "who are you? no idea."
request 3: "give me my settings" ─▶ server: "who are you? no idea."So the whole job of authentication is this: log in once, then prove it is still you on every single request after that, without retyping your password each time. Every method in this article is just a different answer to "how do I keep proving it is me?"
Step one: the login itself
Before any of the clever stuff, you log in. The browser sends your email and password to the server one time.
// The user submits the login form.
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "asha@example.com",
password: "her-real-password",
}),
});The server checks the password against what it has stored (which should be a scrambled, unreadable version, never the plain password). If it matches, the server says "okay, it really is Asha."
Now comes the actual question: the server believes you for this one request. How does it keep believing you for the next thousand requests? There are two famous answers. We will build both.
Answer A: sessions and cookies (the coat-check ticket)
Picture a coat check at a theatre. You hand over your coat. They keep it on a numbered hook and give you a little ticket that just says 42. The ticket itself is meaningless. Anyone reading it learns nothing about you or your coat. But the coat check has a list: ticket 42 belongs to that coat on hook 42. When you come back and show ticket 42, they look it up and hand you your coat.
A session works exactly like this.
When you log in, the server creates a session: a record on its side that says "session abc123 is Asha, logged in at 9am." Then it hands your browser a meaningless ID, abc123, the coat-check ticket. The real information stays with the server. You just carry the ticket.
login ─▶ server saves: { abc123 ─▶ Asha, logged in }
◀─ hands browser the ticket: "abc123"
later request, you show the ticket "abc123"
─▶ server looks it up: "abc123? that's Asha. let her in."Where does the ticket live? In a cookie.
A cookie is a small piece of text the server asks your browser to hold and send back automatically on every future request to that site. You do not write code to attach it each time. The browser just does it. That automatic part is the whole point.
server's reply includes: Set-Cookie: sessionId=abc123
from now on, the browser automatically adds to every request:
Cookie: sessionId=abc123So after login, every request your app makes quietly carries the ticket along, and the server looks it up every time. The user feels logged in. Really they are just flashing ticket 42 over and over.
// Server side (Express), roughly. On login:
app.post("/api/login", (req, res) => {
// ...check the password first...
// Make a session record and get its id.
const sessionId = createSession({ user: "Asha" });
// Ask the browser to hold this ticket.
// httpOnly means JavaScript on the page cannot read it (more on that later).
res.cookie("sessionId", sessionId, { httpOnly: true, secure: true });
res.send({ ok: true });
});The catch with sessions
Because the real information lives on the server, the server has to remember every logged-in person. One user, one record. A million users, a million records sitting in memory or a database, and every single request means a lookup. That works fine, and plenty of huge sites run on sessions. But it ties you to that storage, and it gets fiddly when you have many servers that all need to share the same list of tickets.
That itch is what the second answer tries to scratch.
Answer B: tokens and JWT (the festival wristband)
Now picture a music festival instead of a coat check. At the gate they check your ID once, then snap a wristband on you. The wristband itself has the info printed right on it: your name, "VIP," valid until Sunday. Crucially, it has a tamper-proof seal, a hologram, so a guard can glance at it and trust it without phoning head office. Anyone can read it, but nobody can fake the seal.
That is a token. The most common kind is a JWT.
A JWT (JSON Web Token, said "jot") is a string the server gives you at login that already contains who you are and is signed so it cannot be faked. The server does not keep a record of it. The proof rides along inside the token itself.
The difference from sessions in one line: a session ticket is meaningless and the server remembers everything; a JWT is meaningful and the server remembers nothing. The wristband carries its own truth.
What a JWT actually looks like
It is one long string with two dots in it, splitting it into three parts:
xxxxx.yyyyy.zzzzz
───── ───── ─────
header payload signatureeyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiQXNoYSIsInJvbGUiOiJ2aXAifQ.4f9...a1c
└──── header ────┘ └──────── payload ─────────┘ └─ signature ─┘Each part is just JSON that has been Base64-encoded, which means scrambled into a URL-safe string. Decode it and you get plain readable data:
- The header says how the token is signed:
{ "alg": "HS256" }. The recipe used for the seal. - The payload is the actual info, called "claims":
{ "user": "Asha", "role": "vip", "exp": 1716800000 }. Who you are and when the band expires. - The signature is the hologram. The server made it by signing the first two parts with a secret key only the server knows.
Here is the part beginners always get wrong, so read it twice: the payload is not encrypted. It is just encoded. Anyone can paste a JWT into a website and read what is inside. So never put a password or a secret in there. The signature does not hide the contents; it only proves nobody changed them.
Why the signature makes it tamper-proof
Say an attacker grabs their own token, decodes the payload, and changes "role": "user" to "role": "admin". Sneaky. But the signature was calculated from the original bytes using the server's secret. Change one character of the payload and the signature no longer matches. The server recomputes the seal, sees it does not line up, and throws the token out.
honest token: payload says "user" + matching seal ─▶ ✅ accepted
tampered token: payload says "admin" + old seal ─▶ ❌ seal doesn't match, rejectedThe attacker cannot make a fresh valid seal because they do not have the secret key. That key never leaves the server. That is the entire trick.
The flow end to end
1. login with email + password
─▶ server checks it, builds a JWT, signs it with its secret
◀─ hands the JWT back to the browser
2. browser stores the JWT, then on each request sends:
Authorization: Bearer eyJhbGciOiJI...
3. server gets the JWT, re-checks the signature with its secret
─▶ seal valid + not expired? let them in. No database lookup.Notice step 3 has no lookup. The server does not ask "do I have a record of this token?" It just verifies the seal with its secret key and reads the payload. That is why JWTs scale nicely across many servers: every server that knows the secret can verify any token on its own, no shared list of tickets needed.
// Server side, issuing a JWT on login.
import jwt from "jsonwebtoken";
app.post("/api/login", (req, res) => {
// ...check the password first...
// Put the claims inside, sign with the server's secret, set an expiry.
const token = jwt.sign(
{ user: "Asha", role: "vip" }, // the payload (no secrets in here!)
process.env.JWT_SECRET, // the secret only the server knows
{ expiresIn: "1h" } // the wristband self-destructs in 1 hour
);
res.send({ token });
});// Browser side, sending the token on later requests.
const response = await fetch("/api/profile", {
headers: {
// "Bearer" just means "the bearer of this token is allowed in."
Authorization: `Bearer ${token}`,
},
});The catch with JWTs
The same feature that makes them nice (the server keeps no record) makes them hard to cancel. With sessions, logging someone out is easy: delete their record and ticket 42 stops working instantly. With a JWT, the server is not keeping a list, so a stolen token keeps working until it expires on its own. You cannot easily yank it back.
That is why JWTs are usually given a short life, like fifteen minutes to an hour. Which raises an obvious annoyance: do users get kicked out every hour? No. That is what refresh tokens fix, coming up shortly.
Sessions versus JWT, side by side
SESSION (coat check) JWT (wristband)
───────────────────── ──────────────────
the ticket holds a meaningless id real info about you
server remembers every logged-in user nothing
to verify look it up in storage check the seal, no lookup
logging out delete the record, instant wait for it to expire
many servers they must share the list each verifies on its own
usually stored a cookie a cookie or in JS memoryNeither one is "better." Sessions give you instant control and easy logout. JWTs give you statelessness across many services. Here is the part that surprises people: a lot of large products, Google included, keep using plain cookie sessions for the logged-in browser, and use JWTs mainly as a way to pass identity between services (single sign-on), not as the everyday session token. The popular framing of "sessions versus JWT, pick one" is mostly a false choice. Many real systems run a hybrid: a short-lived JWT for fast checks, plus a server-side record behind the refresh step so they can still revoke. Pick based on what you actually need, not on what is trendy.
Where do you store the token? (the part that gets people hacked)
You have a token. The browser needs to keep it between page loads. There are two common spots, and the choice is a real security decision, not a style preference. Two attacks drive the whole discussion, so meet them first.
XSS (cross-site scripting) is when an attacker manages to run their own JavaScript on your page, maybe through a comment box that did not clean its input. Their script can read anything your own JavaScript can read.
CSRF (cross-site request forgery) is when a sneaky site tricks your browser into making a request to your real site, riding on a cookie the browser attaches automatically.
Now the two storage spots:
localStorage is a little box in the browser that JavaScript can read and write. Easy to use. The problem: if an XSS attack runs a script on your page, that script can read localStorage and walk away with the token. Game over.
// Easy, and exactly what an XSS attacker hopes you did.
localStorage.setItem("token", token);
const token = localStorage.getItem("token");An httpOnly cookie is a cookie the server sets with a flag saying "JavaScript is not allowed to touch this." The browser still sends it automatically, but no script on the page can read it, which shuts the XSS theft down. The trade is that because the browser attaches it automatically, you become open to CSRF, so you add a defense called the SameSite flag, which tells the browser not to send the cookie when the request comes from another site.
// Server sets it. The browser holds it but JS can never read it.
res.cookie("token", token, {
httpOnly: true, // JavaScript on the page cannot read this cookie
secure: true, // only sent over HTTPS, never plain http
sameSite: "lax", // don't send it on requests coming from other sites (CSRF guard)
});The short, honest guidance: prefer an httpOnly cookie with secure and sameSite set. Avoid putting tokens in localStorage unless you have a strong reason and you fully trust every script on your page. The convenience of localStorage is not worth handing your token to the first XSS bug.
"Login with Google": this is OAuth
You have clicked "Continue with Google" a hundred times. You never gave that app your Google password. Yet it knows your email and name. How? That is OAuth, and the everyday version of it is a hotel valet.
When you hand your car to a valet, you do not give them the key to your house, your office, and your safe. You give a valet key: it starts the car and opens the door, nothing more, and only for tonight. You are granting limited access without handing over your master keys.
OAuth is a way to let one app get limited access to your account on another app, without ever giving that app your password.
The cast of characters, in plain words:
- You, clicking the button. The one who owns the account.
- The app you are logging into, say a photo printing site. The one that wants in.
- Google, where your real account lives. The one that vouches for you.
The dance, step by step
1. you click "Login with Google" on the photo site
─▶ the site sends you over to Google's login page
2. Google asks YOU (on Google's own page, not the app's):
"PhotoSite wants your name and email. Allow?"
─▶ you log into Google and click Allow
3. Google sends you back to the photo site with a short-lived code
─▶ like a claim ticket: "?code=xyz789"
4. behind the scenes, the photo site's server trades that code with Google:
"here's the code, plus my app secret. give me the real token."
◀─ Google checks it and hands over an access token
5. the photo site uses that token to ask Google for your name and email
─▶ done. you're "logged in", and PhotoSite never saw your password.The key things to notice. You typed your password only on Google's own page, never on the photo site. The photo site gets a token scoped to exactly what you approved (your email and name), not your whole Google account. And it is revocable: you can go into your Google settings and cut off the photo site any time, like telling the hotel that valet key no longer works.
// Step 1: the button just sends the user to Google with some details.
const params = new URLSearchParams({
client_id: "photo-site-id", // who is asking
redirect_uri: "https://photosite.com/callback", // where to send them back
response_type: "code", // "give me a code, not a token directly"
scope: "email profile", // exactly what we want access to
});
window.location.href =
`https://accounts.google.com/o/oauth2/v2/auth?${params}`;That redirect approach, sending the user to the provider and back, is the heart of every "Login with X" button you have ever used.
OAuth was not really built for login
Small but important footnote. OAuth's actual job is authorization, granting access to stuff (your Google Drive files, your calendar). People bent it into a login mechanism. To do login cleanly, a thin layer called OpenID Connect (OIDC) sits on top of OAuth and adds an ID token, which is, of all things, a JWT describing who you are. So when you "Login with Google," you are usually using OIDC, which uses OAuth, which hands you a JWT. The ideas in this article stack on top of each other; they are not separate islands.
Refresh tokens: staying logged in without staying vulnerable
Remember the JWT problem: you want short-lived tokens for safety, but you do not want to nag the user to log in every fifteen minutes. Refresh tokens square that circle.
You actually get two tokens at login. An access token that is short-lived (say 15 minutes) and used on every request. And a refresh token that lives much longer (days or weeks), is kept somewhere safe, and has exactly one job: get a fresh access token when the old one expires.
Think of a hotel key card that stops working at noon checkout, plus your photo ID at the front desk. When the card dies, you do not re-prove your whole identity from scratch. You show the ID at the desk and they print a new card in seconds.
access token ─ short life (15 min) ─ sent on every request
refresh token ─ long life (2 weeks) ─ used only to get a new access token
access token expires
─▶ app quietly sends the refresh token to /api/refresh
◀─ server checks it, issues a brand new access token
─▶ user never noticed a thing// When a request fails because the access token expired, refresh and retry.
async function fetchWithRefresh(url) {
let res = await fetch(url, { credentials: "include" });
if (res.status === 401) { // 401 = "your token's no good anymore"
// Ask for a new access token using the refresh token
// (sent automatically as an httpOnly cookie).
await fetch("/api/refresh", { method: "POST", credentials: "include" });
// Now retry the original request with the fresh token.
res = await fetch(url, { credentials: "include" });
}
return res;
}This gives you the best of both: if an access token leaks, it is worthless in fifteen minutes, but the user still feels permanently logged in. The refresh token, being precious, lives in an httpOnly cookie and the server usually does keep a record of it so it can be revoked on logout. That is the spot where many real systems quietly reintroduce a little server-side state.
One more you will meet: API keys
Not everything is a person logging in. Sometimes one program talks to another with no human involved, like your server calling a payment provider. For that there are API keys: a single long secret string that says "this program is allowed."
// A server calling another service. No user, just a shared secret.
const res = await fetch("https://api.payments.com/charge", {
headers: { "Authorization": "Bearer sk_live_a1b2c3..." },
});The one rule that matters: an API key like this is a master password for that service, so it lives only on your server, in an environment variable, never in your frontend code. Anything in your frontend can be read by anyone who opens the browser dev tools. Put a secret key there and it is not secret.
"Wait, but..." the questions everyone asks
"If anyone can read a JWT, isn't that a security hole?" No, as long as you treat it right. Reading it is fine; the payload is meant to be readable. The protection is the signature, which stops anyone from changing it. Just never put secrets in the payload, because it is readable by design.
"Why not just send the password on every request?" Because then your password is flying across the network constantly, sitting in logs, and waiting in browser storage. You want it sent exactly once, at login. After that you carry a temporary, limited, expiring proof instead. Smaller blast radius if it leaks.
"Session or JWT, which should I actually use?" For one app on one backend that wants dead-simple logout, sessions are great and underrated. JWTs earn their keep when many separate services need to verify identity without a shared database lookup. A common senior answer: "use a session for the browser, use JWTs to pass identity between services, and don't treat plain JWTs as a drop-in replacement for sessions just because they sound stateless." There is no universally correct pick.
"Is OAuth a replacement for my own login?" It is a complement. "Login with Google" saves users from making yet another password and saves you from storing passwords. But you still need to handle sessions or tokens on your side once they are in, and you may still want a regular email login for people who do not use Google.
"What's the single most common beginner mistake?" Putting the token in localStorage and forgetting about XSS. The second most common is putting a secret key in frontend code. Both hand attackers exactly what they need. Keep tokens in httpOnly cookies and secrets on the server.
Recap in one breath
The web forgets you after every request, so you log in once and then carry a proof on every request afterward. That proof is either a meaningless ticket the server looks up (a session) or a self-contained signed token the server just verifies (a JWT). "Login with Google" is OAuth, letting another site vouch for you without sharing your password. Keep your tokens in httpOnly cookies, keep your secrets on the server, and give tokens short lives with a refresh token to stay logged in safely.
Open your browser's dev tools on any site you are logged into, look at the Application tab, and find the cookies. You will see the exact ticket or token this article is about, quietly riding along on every request you make.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…