You can’t implement secure login authentication without a backend, because anything that runs in the browser, including passwords, secret keys and the “is logged in” check, can be read and changed by the user. What you can do is skip building your own backend: managed authentication services such as Firebase Authentication, Supabase Auth, Auth0 or Clerk run the server side for you, so your front end only calls their SDK.
I wrote the original version of this post for individuals and small projects that don’t have the resources to build a full backend login system. That goal still stands, but the approach it showed (hashing passwords and signing JWTs in the browser) is not safe, so this updated version explains why, demonstrates the problem, and shows the approach I’d recommend instead, tested end to end.
Why Client-Side-Only Login Is Not Secure
To check that I wasn’t overstating the risk, I built a small React app the way many “login without backend” tutorials do, with the user list, password and JWT secret in the front-end code:
// DON'T DO THIS: "authentication" entirely in the browser
const SECRET_KEY = 'my-super-secret-jwt-key';
const USERS = { '[email protected]': 'hunter2' };
export function login(email, password) {
if (USERS[email] === password) {
localStorage.setItem('token', btoa(JSON.stringify({ email, sig: SECRET_KEY })));
return true;
}
return false;
}
export const isLoggedIn = () => Boolean(localStorage.getItem('token'));
export default function App() {
return isLoggedIn() ? <h1>Admin dashboard</h1> : <button onClick={() => login('[email protected]', 'hunter2')}>Log in</button>;
}Then I tested it the way an attacker would:
- Reading the bundle. After a normal production build (minified by Vite), searching the JavaScript file found
my-super-secret-jwt-key,[email protected]andhunter2in plain text. Anyone can open DevTools, view the source and copy them. Minification is not encryption. - Skipping the login. In the browser console I ran
localStorage.setItem('token', 'anything')and reloaded. The app showed “Admin dashboard” without any password.
The underlying rule: the browser is controlled by the user. Every check that runs there can be edited, and every value shipped to it can be read. A JWT is only meaningful if it is signed with a key the user never sees and verified by code the user can’t change, which means a server. Storing tokens in localStorage also exposes them to any cross-site scripting bug (see the OWASP HTML5 security cheat sheet).
Client-side “login” is fine only where nothing is really protected: a demo, a prototype, or hiding UI that holds no private data.
Your Options for Login Without Building a Backend
| Option | What it gives you | Free tier | Good for |
|---|---|---|---|
| Firebase Authentication | Email/password, phone, Google, Apple, GitHub and more; SDKs for web and mobile | Generous for most small apps | Apps already using Firebase or Firestore |
| Supabase Auth | Email, magic links, OAuth; integrates with Postgres row-level security | Yes | Apps that want a Postgres database too |
| Auth0 | Hosted login page, social and enterprise logins, MFA | Yes, with limits | Teams needing enterprise SSO or a hosted login UI |
| Clerk | Drop-in React components for sign-in, sign-up and user profile | Yes, with limits | React and Next.js apps that want ready-made UI |
All of them hash passwords, issue signed tokens, handle password resets and email verification, and protect against brute-force attacks, which is the part that is hard to get right yourself. Pricing and limits change, so check each provider’s pricing page before you commit.
Example: Email and Password Login with Firebase Authentication
Here is the whole flow, client and server, which I ran against the Firebase Authentication emulator (firebase 13.1.0, firebase-admin 14.5.0, firebase-tools 15.34.1). The first half is exactly what your React app calls; the second half is what your API or a serverless function runs to trust a request:
// Client side: what your React app runs (firebase JS SDK)
import { initializeApp } from 'firebase/app';
import {
getAuth, connectAuthEmulator, createUserWithEmailAndPassword,
signInWithEmailAndPassword, signOut,
} from 'firebase/auth';
const app = initializeApp({ apiKey: 'demo-key', projectId: 'demo-login' });
const auth = getAuth(app);
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
const { user } = await createUserWithEmailAndPassword(auth, '[email protected]', 'correct horse battery');
console.log('signed up:', user.email, '| uid:', user.uid.slice(0, 8) + '...');
await signOut(auth);
try {
await signInWithEmailAndPassword(auth, '[email protected]', 'wrong password');
} catch (err) {
console.log('wrong password ->', err.code);
}
const cred = await signInWithEmailAndPassword(auth, '[email protected]', 'correct horse battery');
const idToken = await cred.user.getIdToken();
console.log('signed in, ID token length:', idToken.length);
// Server side: what your API (or a serverless function) runs (firebase-admin)
import { initializeApp as initAdmin } from 'firebase-admin/app';
import { getAuth as getAdminAuth } from 'firebase-admin/auth';
const admin = initAdmin({ projectId: 'demo-login' });
const decoded = await getAdminAuth(admin).verifyIdToken(idToken);
console.log('server verified token for:', decoded.email);
try {
await getAdminAuth(admin).verifyIdToken(idToken.slice(0, -5) + 'xxxxx');
} catch (err) {
console.log('tampered token ->', err.code);
}Output:
signed up: [email protected] | uid: K0zT0qbS...
wrong password -> auth/wrong-password
signed in, ID token length: 491
server verified token for: [email protected]
tampered token -> auth/argument-errorNotes from the test:
- No secrets in the front end. The
apiKeyin a Firebase config is an identifier, not a secret; security comes from Firebase’s servers and your security rules. - Error codes differ in production. The emulator returned
auth/wrong-password, but projects with email enumeration protection (the default for new projects) returnauth/invalid-credentialfor both a wrong password and an unknown email, so attackers can’t discover which emails are registered. Show the user one generic “email or password is incorrect” message. - The server must verify the token. Send the ID token in an
Authorization: Bearerheader and verify it with the Admin SDK on every request. Emulator tokens are not signed, so test signature checks against a real project before you go live. - If you use Firestore or Storage directly from the client, security rules are your backend: lock data to
request.auth.uid.
For a complete React integration with sign-up, login, logout and protected pages, follow my Firebase Authentication with React guide; for mobile, see Firebase Auth with Expo. If you prefer a hosted login page, see how to create an Auth0 protected route in React.
What If I Need a Little Server Logic?
You still don’t need to run a server. Serverless functions (Firebase Cloud Functions, Supabase Edge Functions, Vercel or Netlify Functions, Cloudflare Workers) let you write small pieces of trusted code, such as verifying a token before returning private data or calling a paid API with a secret key. They run on the provider’s infrastructure, so the secret never reaches the browser. I covered one such setup in how to optimize Supabase Edge Functions.
Security Checklist for Front-End Login
- Never put passwords, API secrets or signing keys in front-end code or environment variables that are bundled (
VITE_andNEXT_PUBLIC_variables are public). - Let the provider hash and store passwords; never store them yourself.
- Verify tokens on the server, or with security rules, for every protected read and write.
- Treat route guards in React as user experience only, not as security.
- Prefer the provider’s session handling over copying tokens into
localStorageyourself, and fix XSS bugs, since any script on your page can act as the user. - Turn on email verification, rate limiting and multi-factor authentication where your provider supports them.
Conclusion
Real authentication always needs a trusted server, because the browser can’t keep secrets or enforce rules. The good news is that you don’t have to build that server: Firebase Authentication, Supabase Auth, Auth0 and Clerk handle passwords, tokens and sessions for you, and your front end just calls their SDK. Use one of them, verify tokens wherever data is protected, and keep secrets out of your JavaScript bundle.
FAQs
Can I implement login authentication without a backend?
Not securely on your own. Anything in the browser can be read and modified, so passwords, secret keys and login checks there can be bypassed. Use a managed auth service such as Firebase Authentication, Supabase Auth, Auth0 or Clerk, which provides the backend for you.
Is it safe to store users and passwords in React code?
No. Everything in your JavaScript bundle is public. In my test, a minified production build still contained the email, password and secret key in plain text.
Can I create JWTs in the browser?
You can create them, but they are worthless for security, because the signing secret would have to be in the browser where anyone can copy it and sign their own tokens. JWTs must be signed and verified on a server or by an auth provider.
Is localStorage safe for storing login tokens?
It is readable by any script running on your page, so an XSS bug can steal the token. Prefer your auth provider’s session handling, keep tokens short-lived, and protect against XSS.
What is the easiest way to add login to a React app?
A managed provider. Firebase Authentication and Supabase Auth take a few lines of SDK code; Clerk provides ready-made React sign-in components; Auth0 provides a hosted login page.
Is the Firebase API key a secret?
No. It identifies your Firebase project and is meant to be in client code. Protect data with Firebase security rules and server-side token verification, not by hiding the key.








