Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services

Blog Posts

Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Amine Elbarry

Amine

5+ years software engineer

~/AI_Chat~/projects~/experience~/blogs~/hire-me~/services
Back to all blogs

How to Authenticate Users in a Web Application

Jun 14, 2026•7 min read

Authentication breaks into four questions, and most auth bugs come from answering one of them badly: who are you (identity), how do we remember you (sessions), what are you allowed to do (authorization), and how does access end (logout and expiration). Get all four right and your app is secure at the front door. Get one wrong — long-lived tokens in localStorage, no expiration, plaintext passwords — and everything behind it is exposed. I've built auth for e-commerce apps handling real payments, and the boring, well-trodden approach wins every time. Don't invent auth. Assemble it from known-good parts.

This is a deep dive under my full-stack app guide. Let's take the four parts in order.

Part 1: Verify identity

This is proving the user is who they claim. Several methods, often combined:

Email + password. The default. The one rule that matters most: never store passwords in plaintext, and never store them reversibly encrypted. Hash them with a slow, salted algorithm designed for passwords — Argon2id (my first choice) or bcrypt. These are deliberately expensive to compute, which makes brute-forcing stolen hashes impractical.

js
1import argon2 from 'argon2' 2 3// signup — store the hash, never the password 4const passwordHash = await argon2.hash(password) // salt is built in 5 6// login — verify, don't decrypt (you can't; that's the point) 7const valid = await argon2.verify(user.passwordHash, submittedPassword) 8if (!valid) return res.status(401).json({ error: 'Invalid credentials' })

Return the same generic "Invalid credentials" whether the email is unknown or the password is wrong — telling an attacker which emails exist is an information leak. See OWASP's password storage guidance for the canonical rules.

Social login (OAuth 2.0 + OIDC). "Sign in with Google/GitHub" uses OAuth 2.0 for delegated access and OpenID Connect (OIDC) on top for identity — the provider verifies the user and hands you a signed ID token with their profile. You never see or store their password, which removes a whole category of risk. Use a vetted library or provider; the flow has sharp edges (state parameter, PKCE, token validation) you don't want to implement by hand.

Passkeys (WebAuthn). The modern, phishing-resistant option: a public/private keypair bound to the device, unlocked by biometrics or a PIN. There's no shared secret to steal or phish. I now offer passkeys wherever the audience's devices support them — they're a genuine upgrade over passwords.

Multi-factor (MFA). A second factor — a TOTP app code, or the passkey above — on top of the password. Even a leaked password is useless without the second factor. Offer it, and require it for anything sensitive.

Part 2: Manage sessions

Once identity is verified, the app needs to remember the user across requests, because HTTP is stateless — each request arrives with no memory of the last. Two dominant approaches, and choosing between them is where I see the most confusion.

Server sessions. Store session state on the server (in Redis or a database) and hand the browser a random session ID in a cookie. Every request sends the cookie; the server looks up the session. Easy to revoke — delete the server record and the session is dead instantly.

JWT (JSON Web Tokens). A signed token containing the user's claims. The server verifies the signature instead of a database lookup — stateless and easy to scale horizontally. The downside: you can't easily revoke a JWT before it expires, so you keep them short-lived and pair them with a refresh token.

Here's how I actually decide:

FactorServer sessionsJWT
Browser web app✅ recommended⚠️ only in a cookie, short-lived
Mobile app / third-party API⚠️ workable✅ recommended
Revoke immediately✅ delete the record❌ hard (needs a denylist)
Horizontal scalingneeds shared store✅ stateless
Storageserver-sideclient-side

The rule that prevents the most damage: for browser apps, store the session identifier or token in an HttpOnly, Secure, SameSite cookie — never in localStorage or sessionStorage. A token in localStorage is readable by any JavaScript on the page, so a single XSS vulnerability leaks every user's session. HttpOnly makes the cookie invisible to JavaScript, taking that whole attack class off the table.

js
1res.cookie('session', sessionId, { 2 httpOnly: true, // JS can't read it — blunts XSS token theft 3 secure: true, // HTTPS only 4 sameSite: 'lax', // blunts CSRF 5 maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days 6})

Use JWT in localStorage only for non-browser clients (native mobile, server-to-server) where the XSS threat model doesn't apply. Avoid long-lived JWTs in the browser entirely.

Part 3: Authorize actions

Authentication is who you are; authorization is what you're allowed to do. These are different, and conflating them is a classic mistake — being logged in does not mean you can access everything. A logged-in user must not be able to read another user's data by changing an ID in the URL.

Two layers:

Role-based access control (RBAC) — gate routes by role:

js
1function requireRole(role) { 2 return (req, res, next) => { 3 if (req.user?.role !== role) return res.status(403).json({ error: 'Forbidden' }) 4 next() 5 } 6} 7app.delete('/api/users/:id', requireAuth, requireRole('admin'), handler)

Object-level checks — verify the specific record belongs to the requester. This is the one people forget, and it's the top item on the OWASP API Security Top 10 (Broken Object Level Authorization):

js
1app.get('/api/orders/:id', requireAuth, async (req, res) => { 2 const order = await getOrder(req.params.id) 3 if (!order || order.userId !== req.user.id) { 4 return res.status(404).json({ error: 'Not found' }) // 404, not 403 — don't confirm it exists 5 } 6 res.json(order) 7})

Every authorization decision happens on the server. Hiding a button in the React UI is UX, not security — the request still goes through, so the check must live in the API.

Part 4: Logout and expiration

Access has to end cleanly, both on demand and automatically.

Logout must destroy the session where it actually lives. With server sessions, delete the server record — clearing the cookie alone isn't enough, because a copied cookie would still work. With JWT, remove the cookie and, for high-value systems, add the token to a short-lived denylist until it expires.

js
1app.post('/api/logout', requireAuth, async (req, res) => { 2 await destroySession(req.sessionId) // kill it server-side 3 res.clearCookie('session') 4 res.status(204).end() 5})

Expiration limits the damage of a stolen credential. Give sessions a sensible absolute lifetime, expire idle sessions, and for JWT use short access-token lifetimes (minutes) with longer refresh tokens that can be revoked. No session should live forever.

The architecture I recommend

For a typical browser-based web app, this is my default stack, and it's boring on purpose:

  1. Identity: email + Argon2id password hashing, plus OAuth/OIDC social login and passkeys where they fit. Offer MFA.
  2. Sessions: server-side sessions (or short JWTs) in an HttpOnly, Secure, SameSite cookie.
  3. Authorization: RBAC for routes, plus object-level ownership checks on every record — enforced server-side.
  4. Lifecycle: real server-side logout, absolute and idle expiration, HTTPS everywhere.

For mobile or a public API, swap in JWT with short-lived access tokens and refresh tokens. Everything else holds.

Honestly, unless you have a specific reason, use a maintained auth library or a hosted provider rather than rolling your own — they've handled the edge cases (timing attacks, token rotation, CSRF) you'll otherwise learn about the hard way. Where I do build it myself, I lean on well-audited primitives like argon2 and a battle-tested session library, never hand-rolled crypto.

Where to go next

Auth is one layer of API security, not all of it. Rate limiting, input validation, secret management, and defending against the rest of the OWASP API Security Top 10 are covered in how to secure an API — read it next, since authorization and API security overlap heavily. For the full build, head back to the full-stack app guide.

Related Posts

How to Build a Full-Stack App: A Step-by-Step Guide

The exact path I follow to ship a full-stack app end to end — data model, Node/Express API, database, React frontend, auth, security, and deploy — with links to the deep dives for each step.

How to Connect a React Front End to a Node.js Back End

A React app and a Node.js API are two separate programs talking over HTTP. I show the Express server, the React fetch, why CORS exists, how to set up a dev proxy, project structure, and how to run and deploy both.

How to Secure an API: A Practical Checklist

A layered, defense-in-depth checklist for securing an API — HTTPS, auth, RBAC and object-level checks against BOLA/IDOR, rate limiting, input validation, safe errors, logging, secret management, and the OWASP API Security Top 10. Plus the myths that get people breached.