Building Secure Authentication in a Fullstack App: JWT, bcrypt, and Google OAuth in Practice
Introduction
Building Secure Authentication in a Fullstack App: JWT, bcrypt, and Google OAuth in Practice

Hashed Password on Database
Introduction
Most tutorials teach authentication in pieces — here’s how to hash a password, here’s how to sign a JWT, here’s how OAuth works. But nobody shows you what happens when all three need to live in the same app, serving the same user.
During my internship at Dumbways.id , that’s exactly the problem I had to solve. I built several web applications where users could register with email/password or sign in with Google — and every protected endpoint had to validate the same way regardless of how they authenticated.
This article walks through how I connected bcrypt, JWT, and Google OAuth via Supabase into one coherent auth flow — and what I learned about why each piece exists.
Technical Discussion
Before diving in, here are the four key concepts this article covers:
- Authentication — validating user credentials (password, biometrics, etc.) so the user can access the app.
- Authorization — validating whether an authenticated user has permission to access a specific endpoint.
- bcrypt — safely storing passwords so they’re never exposed as plain text in a database.
- JWT (JSON Web Token) — letting the server verify who a user is without storing session data.
- Google OAuth via Supabase — letting users authenticate through a trusted third party, no password needed.
Part 1: bcrypt — Storing Passwords Safely
Never store plain-text sensitive information (password) on a Database. If your database has ever been hacked, every sensitive information is immediately exposed. That is why we need bcrypt to solve this by turning the password into an irreversible hash (encrypt only, can’t decrypt).
Here’s how a registration route looks:
// register route — hashing before saving
import bcrypt from "bcrypt";
const SALT_ROUNDS = 10;
export const registerUser = async (req, res) => {
const { email, password } = req.body;
// Hash the password before storing
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
const user = await db.users.create({
data: { email, password: hashedPassword },
});
res.status(201).json({ message: "User registered successfully" });
};
On login, you never decrypt the hash. Instead, you re-hash what the user typed and compare:
// login route — comparing without decrypting
export const loginUser = async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findUnique({ where: { email } });
if (!user) return res.status(401).json({ message: "Invalid credentials" });
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
// Proceed to issue JWT (see Part 2)
};
Key insight: SALT_ROUNDS = 10 means bcrypt runs the hashing function 2¹⁰ = 1,024 times. This makes brute-force attacks expensive. The higher the number, the slower the hash — 10 is a good balance for most apps.
Part 2: JWT — Stateless Session Management
Once a user is authenticated (whether via password or OAuth), the server needs a way to recognize them on subsequent requests — without storing session state in a database.
JWT (JSON Web Token) made for this, the server signs a token containing the user’s ID and sends it to the client side. On every protected request, the client sends the token back, and the server verifies the signature/token.
// Issuing a JWT after successful login
import jwt from "jsonwebtoken";
const issueToken = (userId) => {
return jwt.sign(
{ userId }, // payload
process.env.JWT_SECRET, // secret key
{ expiresIn: "7d" } // expiry
);
};
// In your login handler, after bcrypt.compare passes:
const token = issueToken(user.id);
res.json({ token });
Then, a middleware validates the token on every protected route:
// authMiddleware.js
export const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ message: "No token provided" });
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { userId: "..." }
next();
} catch (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
};
Apply it to any route you want to protect:
// Protected route example
router.get("/profile", authenticate, async (req, res) => {
const user = await db.users.findUnique({ where: { id: req.user.userId } });
res.json(user);
});
Part 3: Google OAuth via Supabase
Email-password auth with JWT & bcrypt are enough, but many users still prefer to sign in & sign up with Google OAuth “sign in with google” because they believe in google to keep their data. Remembering the password each time you login to the website is also one of the reasons why we keep OAuth as a standard. Rather than implementing the full OAuth 2.0 flow manually, I used Supabase Auth, which handles the OAuth redirect and callback and returns a session.
The flow looks like this:
User clicks "Sign in with Google"
→ Frontend calls Supabase signInWithOAuth
→ Supabase redirects to Google
→ Google authenticates the user
→ Redirects back to your app with a session
→ You extract the JWT from the Supabase session
On the frontend (Next.js):
// Google OAuth trigger
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const handleGoogleLogin = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) console.error(error);
};
On the callback page:
// /auth/callback — extract session and store token
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
const handleCallback = async () => {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (token) {
localStorage.setItem("token", token);
router.push("/dashboard");
}
};
handleCallback();
}, []);
return <p>Signing you in...</p>;
}
The beauty here: the access_token from Supabase is also a JWT. So your same authenticate middleware on the backend can verify it — you just need to validate against Supabase’s public key instead of your own secret, or simply use Supabase’s server-side client to verify.
Tying It All Together
Both auth paths — email/password and Google OAuth — converge at the same point: a JWT that the frontend stores and sends with every request. This means your protected API routes don’t need to know how the user authenticated. They just check the token.
Email/Password Login → bcrypt.compare → jwt.sign → token Google OAuth → Supabase OAuth → session.access_token → token
Both paths → Bearer token → authenticate middleware → protected routes
Result / Output
After implementing the complete flow, the application supports:
✅ Register with email + hashed password stored in PostgreSQL
✅ Login with email/password → JWT issued on success
✅ Sign in with Google → Supabase OAuth → same JWT pattern
✅ Protected API routes return 401 when no token or expired token is sent
✅ Frontend stores token and attaches it to every API request via Axios interceptors
Token validation response examples:
Valid token:
{ "id": "uuid-123", "email": "faqih@example.com", "name": "Faqih Alam" }
Invalid/expired token:
{ "message": "Invalid or expired token" }
Conclusion
Building this taught me that authentication isn’t one thing — it’s three separate concerns working together:
- bcrypt protects your database: even if someone dumps your users table, passwords aren’t readable.
- JWT makes your server stateless: no session table, no Redis lookup, just a cryptographic signature.
- OAuth delegates identity: Google has already verified this person, so you don’t have to.
Understanding why each layer exists — not just how to use it — is what separates copy-paste auth from auth you can actually reason about and defend.
What I’d improve next time:
- Add refresh tokens so access tokens can be short-lived (15 minutes) without forcing re-login
- Implement token blacklisting on logout using Redis
- Add rate limiting on the /login endpoint to prevent brute-force attacks
If you’re building auth for the first time: resist the urge to skip bcrypt or reuse a JWT tutorial without understanding the flow end to end. Security is the one place where understanding why matters more than shipping fast.
You can see the full implementation in my projects at faqihalam.vercel.app or connect with me on LinkedIn.
REFERENCE:
메타데이터
- post_id
- 6ea2aefc5061
- slug
- building-secure-authentication-in-a-fullstack-app-jwt-bcrypt-and-google-oauth-in-practice-6ea2aefc5061
- url
- https://medium.com/@faqih.alam.ee/building-secure-authentication-in-a-fullstack-app-jwt-bcrypt-and-google-oauth-in-practice-6ea2aefc5061
- canonical_url
- https://medium.com/@faqih.alam.ee/building-secure-authentication-in-a-fullstack-app-jwt-bcrypt-and-google-oauth-in-practice-6ea2aefc5061
- author_url
- https://medium.com/@faqih.alam.ee
- status
- ok
- fetched_at
- 2026-07-11 18:15:18