Building a Lab-Only Login System
So far you've built pages with HTML, set up servers with Express, stored samples in databases, and managed sessions with cookies. At this point, a natural question arises:
"Can't we make this web app accessible only to our lab members?"
If your sample management system contains sensitive experiment data, it shouldn't be open to everyone. You might want to share results only with a collaborative research group, or show presentation materials only to logged-in reviewers.
That's when you need a login system.
From Cookies to Login
In the auth-cookies topic, you learned about cookies and sessions โ the mechanism for the server to remember "this browser belongs to someone who logged in earlier."
But one thing was missing โ how do you verify "the person logging in"? The process of receiving an ID and password, comparing them against what's in the database, and creating a session if they match. Building this yourself leads to complex code and easy security mistakes.
Passport.js is the tool that handles this complex authentication process for you.
Passport.js: Authentication Middleware
Passport.js is an authentication middleware for Express โ a tool built specifically for handling login.
npm install passportnpm install passport-localThe core concept is strategies. Each authentication method has its own strategy package:
| Strategy | Package | Auth Method |
|---|---|---|
| Local | passport-local | Email + password |
passport-google-oauth20 | Google account | |
| GitHub | passport-github2 | GitHub account |
| JWT | passport-jwt | JSON Web Token |
Passport.js currently has over 300 registered strategies. Just install the strategy for the auth method you need.
Passport Initial Setup: Plugging into Express
The basic configuration for connecting Passport to Express:
const express = require("express");
const session = require("express-session");
const passport = require("passport");
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: "lab-secret-key",
resave: false,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());passport.initialize() โ registers Passport with Express.
passport.session() โ restores user information stored in the session on every request.
Used together with express-session from the auth-cookies topic. If sessions are "memory," Passport decides "who should be remembered."
serializeUser and deserializeUser
When a user logs in, you need to store their info in the session. But putting the entire user object in the session is heavy. Passport solves this with the serialize/deserialize pattern:
passport.serializeUser(function(user, done) {
done(null, user.id);
});
passport.deserializeUser(function(id, done) {
db.query("SELECT * FROM users WHERE id = ?", [id], function(err, rows) {
done(err, rows[0]);
});
});serializeUser โ runs on login success. Decides what to store in the session. Usually just user.id.
deserializeUser โ runs on every request. Finds the user in the database using the stored id and puts it in req.user.
Analogy โ writing only the membership number on a library card (serialize), then looking up member info by that number later (deserialize).
Local Authentication: Email + Password
The most basic login method. The user enters email and password, the server checks against the database, and processes the login.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const bcrypt = require("bcrypt");
passport.use(new LocalStrategy(
{ usernameField: "email" },
function(email, password, done) {
db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {
if (err) return done(err);
if (rows.length === 0) return done(null, false, { message: "Unregistered email" });
const user = rows[0];
const isMatch = bcrypt.compareSync(password, user.password_hash);
if (!isMatch) return done(null, false, { message: "Incorrect password" });
return done(null, user);
});
}
));done(null, user) โ authentication success. Passport passes this user to serializeUser.
done(null, false) โ authentication failure. Not an error, but login denied.
done(err) โ server error (DB connection failure, etc.).
What Passport handles:
- Passes email/password from the login form to the strategy
- On auth success:
serializeUserโ stores user ID in session - On subsequent requests:
deserializeUserโ access the logged-in user viareq.user
Same pattern as Express middleware (app.use()) you learned before. Plug Passport in as middleware, and you can check the logged-in user with req.user in routes.
Registration: Adding New Researchers
Login requires registration first. The full flow:
User โ Registration form (enter email, password)
โ Server: Check email duplication
โ Server: Hash the password (bcrypt)
โ Server: Store hashed password in database
โ Login enabledImplemented as an Express route:
const bcrypt = require("bcrypt");
app.post("/register", function(req, res) {
const { email, password, name } = req.body;
// 1. Check email duplication
db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {
if (rows.length > 0) {
return res.status(400).send("Email already registered");
}
// 2. Hash the password
const hashedPassword = bcrypt.hashSync(password, 10);
// 3. Save to database
db.query(
"INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)",
[email, hashedPassword, name, "member"],
function(err) {
if (err) return res.status(500).send("Registration failed");
res.redirect("/login");
}
);
});
});Never store passwords in plain text. Always convert them with a hash function like bcrypt before storing.
const bcrypt = require("bcrypt");
// Registration: plain text โ hash
const hashedPassword = bcrypt.hashSync("myLabPassword", 10);
// โ Stores hash like "$2b$10$X7YKz..."
// Login: compare input hash with stored hash
const isMatch = bcrypt.compareSync("myLabPassword", hashedPassword);
// โ true or falsebcrypt.hashSync(password, 10) โ 10 is the salt round (hash iteration count). Higher = more secure but slower. 10 is the commonly recommended value.
This is like lab security. If building access card codes were stored in plain text, every door opens when the database is breached. Hashing is like making cards impossible to duplicate.
Login/Logout Routes
After registration, the actual login/logout routes:
// Render login page
app.get("/login", function(req, res) {
res.send(`
<form action="/login" method="POST">
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Login</button>
</form>
`);
});
// Process login โ Passport auto-runs LocalStrategy
app.post("/login",
passport.authenticate("local", {
successRedirect: "/dashboard",
failureRedirect: "/login",
})
);
// Logout
app.get("/logout", function(req, res) {
req.logout(function(err) {
if (err) return res.status(500).send("Logout failed");
res.redirect("/");
});
});passport.authenticate("local") โ specifying the strategy name "local" tells Passport to run that strategy. On success it goes to successRedirect, on failure to failureRedirect. "local" is the default name registered by passport-local.
Access Control: Who Can See What
The real purpose of a login system is access control. "This page is for logged-in users only," "This feature is admin only" โ that's what we implement.
// Middleware allowing only logged-in users
function requireLogin(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect("/login");
}
// Sample data โ login required
app.get("/samples/confidential", requireLogin, function(req, res) {
res.json({ data: "Confidential experiment data", user: req.user.email });
});
// Public page โ anyone can access
app.get("/publications", function(req, res) {
res.json({ papers: publicPapers });
});These scenarios are common in bio research:
| Scenario | Access Control |
|---|---|
| Internal lab sample management | Lab members only |
| Collaborative research data sharing | Registered research group only |
| Research presentation materials | Logged-in reviewers only |
| Equipment reservation system | Logged-in researchers only |
| Results dashboard | PI sees all, students see own data only |
Multiple Users and Roles
With multiple users, you need roles:
// User table structure
// id | email | password_hash | role
// 1 | pi@lab.com | $2b$10$... | admin
// 2 | grad1@lab.com | $2b$10$... | member
// 3 | intern@lab.com | $2b$10$... | viewer
function requireAdmin(req, res, next) {
if (req.isAuthenticated() && req.user.role === "admin") {
return next();
}
res.status(403).json({ error: "Admin privileges required" });
}
// Delete sample โ admin only
app.delete("/sample/:id", requireAdmin, function(req, res) {
// deletion logic
});PI (professor) is admin, grad students are members, interns are viewers โ translating the lab's permission structure directly into code.
OAuth 2.0: How Social Login Works
You've likely seen "Sign in with Google" buttons. That's OAuth 2.0.
The core principle explained with an analogy โ it's like the hotel key card issuance process:
1. Guest (user) wants to check into hotel (my web app)
2. Hotel can't verify identity on its own
3. Hotel: "Please verify your identity at the front desk (Google)"
4. Guest shows passport (Google ID/password) at front desk
5. Front desk issues temporary voucher (Authorization Code)
6. Hotel exchanges voucher + business license (Client Secret) for key card (Access Token)
7. Key card allows lookup of guest info (name, email)Key point: my web app never sees the user's Google password. Google verifies identity on our behalf and only passes a token saying "this person is verified."
OAuth Cast of 3
| Role | Identity | Analogy |
|---|---|---|
| Resource Owner | User (researcher) | Hotel guest |
| Client | My web app (sample management system) | Hotel |
| Resource Server | Google, GitHub, etc. | Front desk (identity verification) |
Using Google OAuth with Passport.js:
const GoogleStrategy = require("passport-google-oauth20").Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback"
},
function(accessToken, refreshToken, profile, done) {
// profile.emails[0].value โ user email
// Find in database or create new
done(null, user);
}
));
app.get("/auth/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);clientID and clientSecret are obtained from Google Cloud Console โ the process of registering "I'm building this app and want to delegate user authentication to Google."
OAuth Key Terms
Client ID and Client Secret
When you register an app in Google Cloud Console, you receive two things:
| Term | Analogy | Purpose |
|---|---|---|
| Client ID | Business registration number | Can be public. Identifies "this app is requesting auth" |
| Client Secret | Company seal | Strictly private. Used only for server โ Google secret authentication |
If Client Secret is leaked, others can make auth requests to Google pretending to be your app.
Redirect URI (Callback URL)
The address registered as "after Google login, send them back here":
https://my-lab-app.com/auth/google/callbackThe Redirect URI registered in Google Cloud Console and the callbackURL in your code must match exactly. Even one character difference causes an error. This is a security measure โ preventing auth results from going to the wrong site.
Scope: The Valet Key Analogy
OAuth's scope defines "how much access to allow."
Think of valet parking. When you hand over a valet key โ they can start the car and park it, but can't open the trunk and the glove box stays locked. Only the necessary permissions are granted.
passport.authenticate("google", { scope: ["profile", "email"] })| scope | Access Range | Valet Key Analogy |
|---|---|---|
profile | Name, profile picture | Driver's seat access (can park) |
email | Email address | Dashboard check (contact info) |
drive.readonly | Read Google Drive files | Opening trunk (checking cargo) |
calendar | Read/write calendar | Modifying in-car schedule |
For our lab login, profile and email are sufficient. We only need to know "who this person is." Requesting unnecessary scopes makes users nervous, and Google may require app review.
Authorization Code Flow (Full Flow)
The safest and most common OAuth 2.0 method:
1. User clicks "Sign in with Google"
โ Browser navigates to Google login page
2. User enters Google ID/password
โ Google asks "Allow this app to access your name and email?"
3. User clicks "Allow"
โ Google sends Authorization Code to our server's Redirect URI
4. Our server sends Authorization Code + Client Secret to Google
โ Google issues Access Token
5. Our server queries user profile (name, email) with Access Token
โ Passport's callback function processes login with this infoKey point: Authorization Code is a one-time voucher. It alone can't access user info โ it must be combined with Client Secret to exchange for an Access Token. As long as both aren't compromised simultaneously, it's secure.
Google OAuth Route Setup
The actual Express routes:
// 1. "Sign in with Google" button leads here
app.get("/auth/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);
// 2. Google redirects here after auth (Redirect URI)
app.get("/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
function(req, res) {
res.redirect("/dashboard");
}
);From the user's perspective, pressing one "Sign in with Google" button is all it takes. Behind the scenes, Authorization Code โ Access Token โ profile lookup happens automatically.
Why Use Social Login
| Benefit | Description |
|---|---|
| User convenience | No need to create a new password |
| Security delegation | Password storage/management burden delegated to Google |
| Reliability | Google already provides 2FA, etc. |
| Quick implementation | Just install Passport strategy + configure |
Especially useful for internal lab tools. Researchers already have Google accounts, so they can access immediately with a single "Sign in with Google" click without separate registration.
Big Picture: Authentication System Layers
Stacking everything learned so far:
[auth-cookies] Cookies & Sessions โ "I remember this browser"
โ
[login-system] Login โ "I verify who you are"
โ
โโโ Local auth (email + password + bcrypt)
โโโ OAuth 2.0 (Google/GitHub social login)
โโโ Role-based access control (admin/member/viewer)If cookies/sessions are "memory," login is "verification," and roles are "permissions." Building analogy โ even with an access badge (cookie), you can't enter every room. Some rooms require a PI card, others open with any researcher card.
Try It Yourself (Faded Example)
Fill in the blanks to complete a Passport.js local authentication strategy.
const passport = require("passport");const = require("passport-local").Strategy;const bcrypt = require("bcrypt");passport.use(new LocalStrategy({ usernameField: "" },function(email, password, done) {db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {if (rows.length === 0) return done(null, );const user = rows[0];const isMatch = bcrypt.(password, user.password_hash);if (!isMatch) return done(null, false);return done(null, );});}));
Next Steps
This topic aims to understand the structure and flow of login systems. In actual implementation:
passport-local+bcryptโ implement email/password loginpassport-google-oauth20โ add Google social login- Role middleware โ access control with
requireLogin,requireAdmin, etc. - HTTPS โ ensure login info isn't transmitted in plain text over the network
BioPlayground itself uses Supabase's authentication system. Whether you implement with Passport.js directly or use a BaaS like Supabase โ the concepts from this topic (hashing, OAuth flow, role-based control) apply equally.
Common Errors & Solutions
Q: What happens if I store passwords without hashing?
If the database is breached, all user passwords are exposed. Since many people reuse the same password across multiple sites, one breach leads to cascading damage. Hashing is not optional โ it's mandatory.
Q: Where should I store the OAuth Client Secret?
Never put it directly in code. Store it in environment variables (.env file) and add .env to .gitignore so it doesn't get uploaded to Git. If leaked, someone can impersonate your app.
Q: Can I build login without Passport.js?
Yes. You can compare passwords with bcrypt and store user info in sessions yourself. But implementing social login, session serialization, error handling, etc. manually makes code complex. Passport.js is a framework that reduces this repetitive work.
Q: If I use Supabase, do I not need to build this myself?
Correct. Supabase provides email/password auth, Google social login, and role-based access control through configuration alone. But to understand "what Supabase is doing internally," you need the concepts from this topic. Someone who understands the principles solves problems faster, even when using tools.