How to Add Authentication to Your Web App: A Developer's Guide
A practical guide to adding real auth to a web app — covering sessions, verification codes, roles, and the common mistakes developers make.
Authentication Is Not Optional — Here’s How to Do It Right
Authentication is one of those things every web app needs, but most developers get wrong the first time. Not wrong in an obvious, everything-breaks way — wrong in a subtle, we-shipped-it-and-now-we-have-a-problem way. Leaked session tokens, passwords stored without proper hashing, verification codes that never expire, admin routes that rely on the frontend to protect them.
This guide is a practical walkthrough of adding real authentication to a web app. It covers the core concepts, the actual implementation steps for common patterns (sessions, verification codes, OAuth, roles), and the mistakes worth avoiding before you find out about them the hard way.
What Authentication Actually Involves
Authentication answers one question: who is this user? Authorization answers a different question: what are they allowed to do? They’re often conflated, but keeping them separate in your architecture saves pain later.
A working auth system typically includes:
- Identity verification — confirming the user is who they say they are (password, magic link, OAuth, passkey, etc.)
- Session management — keeping them logged in securely across requests
- Token or cookie handling — how the server and client exchange proof of identity
- Account lifecycle — registration, email verification, password reset, deletion
Every serious web app needs all of these. Skipping any one of them creates a gap that either breaks usability or creates a security hole — usually both.
Prerequisites
Before writing any auth code, have these things in place:
- A backend with a database. Auth requires server-side logic. If you’re only running a frontend, you need to add a backend layer or use a backend-as-a-service. See the comparison of Supabase vs Firebase if you’re still choosing your backend stack.
- HTTPS in production. Sending credentials over HTTP is a non-starter. Every production auth system assumes HTTPS.
- An email sending service. You’ll need this for verification emails and password resets. Resend, Postmark, and SendGrid are all solid choices.
- Environment variables for secrets. Never hardcode JWT secrets, API keys, or session secrets. Use
.envand keep them out of version control.
Step 1: Choose Your Auth Strategy
There are several patterns for authenticating users. Pick the right one for your use case before writing code.
Username + Password
The classic approach. Users provide a password, you hash it, store the hash, and compare on login. Still the most common pattern for apps where users register directly.
When to use it: Most web apps. Simple, well-understood, widely supported by libraries.
Magic Links / Passwordless Email
The user enters their email, you send them a short-lived link or code, and clicking it logs them in. No password stored, ever.
When to use it: B2B SaaS tools, apps where users log in infrequently, or when you want to reduce support burden from forgotten passwords.
OAuth / Social Login
Delegate identity to a third party — Google, GitHub, Apple. The user authenticates with that provider, which gives you a token you can verify.
When to use it: Consumer apps where friction reduction matters, developer tools (GitHub login is expected), or any app where managing passwords yourself sounds like more work than it’s worth.
Passkeys / WebAuthn
The user authenticates with biometrics or a hardware key. Increasingly supported by browsers and platforms.
When to use it: Security-sensitive apps, or if you want to be ahead of the curve. The support story has improved significantly but still requires fallback options.
Most apps end up combining these. Social login plus email/password is a common starting point. You can always add more later.
Step 2: Hash Passwords Correctly
If you’re going with username + password, the most important thing you can do is hash correctly. This is where the most consequential mistakes happen.
Use bcrypt, Argon2, or scrypt. Do not use SHA-256, MD5, or any general-purpose hash function for passwords. Those are fast — which is the opposite of what you want. Password hashing needs to be intentionally slow to make brute-force attacks expensive.
Here’s what password registration looks like with bcrypt in Node:
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
async function hashPassword(plaintext: string): Promise<string> {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
return bcrypt.compare(plaintext, hash);
}
Store only the hash, never the plaintext. When the user logs in, call verifyPassword and check the result.
A salt rounds value of 12 is a reasonable default in 2025. Higher is more secure but slower — 14 takes noticeably longer on login, which is fine for most apps but adds up if you’re validating at scale.
Step 3: Set Up Sessions
Once a user authenticates, you need to keep them logged in. There are two main approaches: server-side sessions and stateless tokens (JWTs).
Server-Side Sessions
The server stores session data (user ID, expiry) and gives the client a session ID in a cookie. On each request, the server looks up the session ID to identify the user.
Pros: Easy to invalidate (delete the session), no sensitive data on the client.
Cons: Requires session storage (Redis or a database table), doesn’t scale horizontally as easily.
A minimal session implementation:
// Create session after login
async function createSession(userId: string): Promise<string> {
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.sessions.insert({
id: sessionId,
userId,
expiresAt,
});
return sessionId;
}
// Validate session on each request
async function getSessionUser(sessionId: string): Promise<User | null> {
const session = await db.sessions.findFirst({
where: { id: sessionId, expiresAt: { gt: new Date() } },
});
if (!session) return null;
return db.users.findOne({ id: session.userId });
}
Set the session ID as an HttpOnly, Secure, SameSite=Lax cookie. These three attributes together prevent the most common cookie-based attacks.
Stateless JWTs
The server issues a signed token containing the user’s ID and expiry. The client sends this token on every request and the server verifies the signature without any database lookup.
Pros: Scales easily, works well for APIs.
Cons: Can’t invalidate a token before it expires (you’d need a blocklist, which brings back the database anyway), token size grows with claims.
For most web apps where you want proper logout and the ability to revoke access, server-side sessions are simpler to reason about. JWTs make sense for stateless APIs, mobile apps, or microservices architectures where session storage across services is a headache.
Step 4: Add Email Verification
Letting unverified users into your app creates problems: fake accounts, spam, and abuse. Email verification is a required step, not an optional one.
The Flow
- User registers with email + password
- You create their account with
emailVerified: false - You generate a short-lived verification token and send it via email
- User clicks the link — your server validates the token and marks
emailVerified: true - Unverified users can’t access protected features
Here’s what the token generation and validation look like:
import crypto from 'crypto';
async function sendVerificationEmail(userId: string, email: string) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await db.emailVerifications.insert({ userId, token, expiresAt });
const verifyUrl = `https://yourapp.com/verify-email?token=${token}`;
await sendEmail({ to: email, subject: 'Verify your email', body: verifyUrl });
}
async function verifyEmail(token: string): Promise<boolean> {
const record = await db.emailVerifications.findFirst({
where: { token, expiresAt: { gt: new Date() } },
});
if (!record) return false;
await db.users.update({ id: record.userId, emailVerified: true });
await db.emailVerifications.delete({ id: record.id });
return true;
}
Key points: generate tokens with crypto.randomBytes, not Math.random(). Delete the token after it’s used. Set an expiry — 24 hours is standard.
Verification Codes Instead of Links
Some apps prefer 6-digit codes (like OTPs) over links — especially useful for mobile flows or when clicking a link in email is awkward.
Same idea, different format. Generate a random 6-digit number, store it with a short expiry (15 minutes is common), and verify it on submission. Rate limit the verification endpoint to prevent brute-forcing the code.
Step 5: Implement Password Reset
Users forget passwords. Your reset flow needs to be both secure and actually usable.
The Secure Flow
- User submits their email on a “forgot password” form
- Your server looks up the account — but responds with the same message whether the email exists or not (don’t leak whether an account exists)
- If the account exists, generate a reset token and email a link
- User clicks the link, enters a new password
- Server validates the token, updates the password hash, invalidates the token and all existing sessions
Step 2 is where most implementations slip. Returning “no account with that email” is an account enumeration vulnerability — someone can use your forgot-password form to discover which emails have accounts.
The token handling is identical to email verification tokens. Use crypto.randomBytes, store the hash of the token (not the token itself), set an expiry of 1 hour or less, and delete it after use.
Also: when a user resets their password, invalidate all their existing sessions. Someone who got access via a compromised password shouldn’t stay logged in after the password changes.
Step 6: Add OAuth (Social Login)
OAuth is complex to implement from scratch. Use a library. Passport.js for Node, Auth.js (formerly NextAuth) for Next.js, and Lucia for a more lightweight option are all solid choices.
The core OAuth flow:
- User clicks “Sign in with Google”
- You redirect them to Google’s OAuth endpoint with your
client_idand astateparameter (random, stored in session to prevent CSRF) - Google redirects back to your callback URL with a
code - Your server exchanges the
codefor an access token using yourclient_secret - You use the access token to fetch the user’s profile
- You create or look up the local user account and log them in
The state parameter in step 2 is non-optional. Without it, you’re vulnerable to CSRF attacks that can force a user to link their account to an attacker-controlled OAuth identity.
When a user signs in via OAuth, store their provider ID (e.g., google_user_id) in your database linked to your local user record. This lets you handle the case where the same user signs in with different methods — they should land in the same account.
Step 7: Add Role-Based Access Control
Once users are authenticated, you need authorization: controlling what they can do based on their role.
Basic RBAC
Add a role field to your users table. Common values: user, admin, moderator. Expand as needed.
type UserRole = 'user' | 'admin' | 'moderator';
function requireRole(requiredRole: UserRole) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) return res.status(401).json({ error: 'Unauthenticated' });
if (user.role !== requiredRole) return res.status(403).json({ error: 'Forbidden' });
next();
};
}
// Usage
router.delete('/admin/users/:id', requireAuth, requireRole('admin'), deleteUser);
Authorization in the Backend, Not the Frontend
This is the rule that gets broken most often: never rely on the frontend to enforce authorization.
Hiding an admin button in the UI is not authorization. If the API endpoint doesn’t check the role, anyone with a token can call it directly. Every sensitive operation needs a backend check — middleware that validates the user’s role before the handler runs.
If you’re building a SaaS with multiple tiers, the same principle applies. Don’t just disable features in the UI. Gate them in the API.
Step 8: Secure Your Auth Endpoints
Auth routes are higher-value targets than most of your app. A few specifics:
Rate limit everything auth-related. Login, registration, password reset, email verification — all of them. A library like express-rate-limit makes this straightforward. Too many failed login attempts from the same IP should result in a cooldown, not an open door for credential stuffing.
Implement account lockout carefully. Locking an account after N failed attempts prevents brute force but enables denial-of-service (attacker locks every account). A better pattern: exponential backoff + CAPTCHA after repeated failures, rather than hard lockout.
Log auth events. Failed logins, password changes, email changes — log them with timestamps and IPs. This is your audit trail if something goes wrong. For enterprise contexts, this becomes a compliance requirement — see the discussion on building apps with compliance requirements in mind.
Use CSRF protection on state-changing endpoints. If you’re using cookies for auth, protect your mutation endpoints. The SameSite=Lax cookie attribute handles most cases for same-origin navigation, but for more complex setups, CSRF tokens are still the right call.
Common Mistakes Developers Make
Storing Passwords Reversibly
If someone can decrypt a password, you have a problem. Passwords should be hashed, not encrypted. Hashing is one-way by design. If you find yourself needing to retrieve a plaintext password, the architecture is wrong.
Trusting the Client for Authorization
As noted above: the frontend lies. It can be modified, bypassed, or entirely ignored. Backend checks are the only ones that matter.
Short-Lived Session Expiries with No Refresh
Setting a 30-minute session expiry sounds secure, but if you don’t implement token refresh, users get logged out constantly. The result is either frustrated users or, worse, a developer removing the expiry entirely. Use a sensible expiry (7–14 days for web apps) with a rolling refresh on activity, and an absolute expiry for high-security contexts.
Not Invalidating Sessions on Password Change
When a user changes their password, all existing sessions should be invalidated. If an attacker got access, this cuts them off the moment the user reacts.
Leaking Account Existence
“That email isn’t registered” helps users — and also helps attackers enumerate accounts. Always return a generic response for auth errors that would otherwise reveal whether an email exists.
Using the Same Token Twice
Verification tokens, password reset links, magic links — all single-use. Delete them the moment they’re consumed. A token that can be used multiple times can be replayed.
Building Auth From Scratch When You Don’t Have To
This guide covers the fundamentals, but unless auth is central to your product, there’s a real argument for using a managed auth service (Auth0, Clerk, Supabase Auth) rather than implementing everything yourself. The basics are learnable. The edge cases — token refresh, device management, suspicious login detection, social login edge cases — take real time to get right. Backend platform choices matter here, and if you’re still evaluating, comparing Supabase and Firebase is worth your time since both include managed auth.
How Remy Handles Authentication
If you’re building a new full-stack app, there’s a faster path than implementing all of this by hand.
Remy compiles annotated specs into full-stack apps — backend, database, frontend, auth, deployment. When you describe authentication requirements in your spec, Remy generates the corresponding backend methods, database schema, and session handling. That means real verification codes, real session tokens stored in a real SQL database, and real middleware that protects routes.
This is different from frontend-only AI builders that generate a sign-in form but leave the backend wiring to you. (If you’ve run into that frustration, the comparison of full-stack AI app builders is a useful read.) Remy generates the full stack: the createSession, verifyEmail, and requireRole logic isn’t stubbed out — it’s there, it’s wired up, and it matches what you described.
The spec stays in sync with the app as it evolves. Add a new role, update the verification flow, change from sessions to JWT — you update the spec and recompile. The source of truth never drifts from what’s actually deployed.
You can try it at mindstudio.ai/remy.
Frequently Asked Questions
What’s the difference between authentication and authorization?
Authentication confirms who a user is. Authorization determines what they’re allowed to do. Login is authentication. Checking whether a user has the admin role before allowing them to delete a record is authorization. You need both, and they belong in separate layers of your code.
Should I use JWTs or sessions for my web app?
For most web apps, server-side sessions are simpler and easier to manage. JWTs are stateless, which helps with horizontal scaling and API access, but they can’t be invalidated without maintaining a blocklist — which partially defeats the purpose. If you have a traditional web app and aren’t building a distributed microservices architecture, sessions are usually the right call. If you’re building a public API consumed by mobile apps or third-party clients, JWTs make more sense.
Do I need to build auth myself, or should I use a library?
Using a library or managed service is usually the right call unless auth is your product’s core value proposition. Implementing OAuth from scratch, handling all the edge cases for password reset and email verification, and keeping up with security best practices is significant scope. Libraries like Auth.js and managed services like Clerk or Supabase Auth cover most cases well. The best backend platforms for indie hackers covers some of these options if you’re evaluating.
How should I handle users who sign in with multiple methods?
A user might register with email/password, then try to sign in with Google using the same email address. Handle this by matching on verified email address — if the email matches an existing account, link the OAuth identity to that account rather than creating a duplicate. Just make sure the email is actually verified by the OAuth provider (Google and GitHub both verify emails) before treating it as a match.
What’s the safest way to store session tokens?
For browser-based apps: HttpOnly cookies with Secure and SameSite=Lax attributes. HttpOnly prevents JavaScript from accessing the cookie, blocking XSS token theft. Secure ensures it’s only sent over HTTPS. SameSite=Lax prevents most CSRF attacks. Don’t store session tokens in localStorage — it’s readable by any JavaScript running on the page, which is a real attack surface.
How do I add multi-factor authentication (MFA)?
TOTP (time-based one-time passwords, like Google Authenticator) is the most common MFA method. After verifying a username/password, check whether the user has MFA enabled. If yes, prompt for a 6-digit code from their authenticator app, validate it server-side using a TOTP library (like otplib for Node), and only create a session after both factors pass. Store the TOTP secret encrypted in your database. Never log it, never expose it after the initial setup flow.
Key Takeaways
- Authentication requires a real backend — there’s no secure client-only solution.
- Hash passwords with bcrypt, Argon2, or scrypt. Never store plaintext or use fast hash functions.
- Use
HttpOnly,Secure,SameSite=Laxcookies for session tokens. - Verify emails before allowing access to protected features.
- All authorization checks belong in the backend, not the frontend.
- Rate limit auth endpoints and log auth events.
- Invalidate sessions on password change.
- Consider a managed auth service for most apps — the edge cases in a custom implementation are real and time-consuming.
If you’re starting a new project and want the full stack handled — backend, database, auth, deployment — without implementing each piece manually, try Remy. The spec format lets you describe your app’s auth requirements precisely, and the generated output is real code you can read, extend, and deploy.