CSCI 4513
Week 15, Lecture 26
In Asgard stands Valhalla, Odin's magnificent hall where only the worthy may enter. Valkyries choose the fallen warriors (authentication), grant them entry (authorization), and assign them roles (permissions)—just as we authenticate users, authorize access, and manage permissions.
The Tale:
Valhalla's golden shields gleam in the eternal light of Asgard. At its gates stand the Valkyries, Odin's shield-maidens, who have chosen the brave warriors from battlefields across Midgard. But entry is not automatic—each warrior must prove their identity and worthiness.
The Valkyrie's Verification: First, the Valkyrie verifies the warrior's identity—did they truly fall in glorious battle? This is authentication (proving who you are). She checks their battle scars, their weapons, their stories against the records in Odin's great hall.
Granting Entry: Once verified, the warrior is granted entry through Valhalla's massive gates. This is authorization (what you're allowed to do). But not all warriors have the same privileges.
The Einherjar's Ranks: Some warriors become Einherjar—the honored dead who will fight at Ragnarök. These have special privileges: access to Odin's table, training with the gods, and knowledge of secret battle tactics. Others are guests, welcome but with limited access. This represents different authorization levels (regular users, members, admins).
The Golden Armband: Each verified warrior receives a golden armband—a token of their authenticated status. This armband is their session token. When they approach the mead hall or the training grounds, guards check their armband rather than re-verifying their entire battle history every time.
Encrypted Secrets: Some warriors carry messages from Odin, sealed with magic runes that only certain recipients can read. These sealed messages are like JWT tokens—encoded information that proves authenticity without needing to query Odin every time.
Our Lesson: Today we build Valhalla. We create gates (login forms), station Valkyries (Passport.js), forge armbands (sessions), craft rune-sealed messages (JWTs), and organize the halls (authorization levels). By lesson's end, you'll guard your applications as fiercely as Valhalla guards its warriors.
Authentication is crucial for modern web applications. We'll use Passport.js, an authentication middleware for Express, along with bcrypt for password security.
# Install required dependencies
npm install express express-session pg passport passport-local ejs bcryptjs
# express-session: Session management
# passport: Authentication middleware
# passport-local: Username/password strategy
# bcryptjs: Password hashing
-- Create users table in PostgreSQL
CREATE TABLE users (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
username VARCHAR ( 255 ),
password VARCHAR ( 255 )
);
const path = require("node:path");
const { Pool } = require("pg");
const express = require("express");
const session = require("express-session");
const passport = require("passport");
const LocalStrategy = require('passport-local').Strategy;
const pool = new Pool({
host: "localhost",
user: "your_user",
database: "your_database",
password: "your_password",
port: 5432
});
const app = express();
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.use(session({
secret: "cats",
resave: false,
saveUninitialized: false
}));
app.use(passport.session());
app.use(express.urlencoded({ extended: false }));
// 1. Session middleware FIRST
app.use(session({ secret: "cats", resave: false, saveUninitialized: false }));
// 2. Passport session SECOND (depends on express-session)
app.use(passport.session());
// 3. Body parser for form data
app.use(express.urlencoded({ extended: false }));
// 4. Routes come AFTER middleware setup
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign Up</title>
</head>
<body>
<h1>Sign Up</h1>
<form action="/sign-up" method="POST">
<label for="username">Username</label>
<input id="username" name="username"
placeholder="username" type="text" />
<label for="password">Password</label>
<input id="password" name="password" type="password" />
<button type="submit">Sign Up</button>
</form>
</body>
</html>
// GET route to show form
app.get("/sign-up", (req, res) => res.render("sign-up-form"));
// POST route to create user
app.post("/sign-up", async (req, res, next) => {
try {
await pool.query(
"INSERT INTO users (username, password) VALUES ($1, $2)",
[req.body.username, req.body.password]
);
res.redirect("/");
} catch(err) {
return next(err);
}
});
Strategies are authentication mechanisms. Passport.js has over 500 strategies:
Each strategy defines how to verify credentials.
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
const { rows } = await pool.query(
"SELECT * FROM users WHERE username = $1",
[username]
);
const user = rows[0];
if (!user) {
return done(null, false, { message: "Incorrect username" });
}
if (user.password !== password) {
return done(null, false, { message: "Incorrect password" });
}
return done(null, user);
} catch(err) {
return done(err);
}
})
);
// done() has three parameters: done(error, user, info)
// 1. Authentication error (database error, etc.)
return done(err); // Something went wrong
// 2. Authentication failed (wrong credentials)
return done(null, false, { message: "Incorrect username" });
// error: null, user: false, info: error message
// 3. Authentication successful
return done(null, user);
// error: null, user: user object
passport.authenticate()
The Problem: HTTP is stateless. Each request is independent. How do we remember that a user is logged in?
The Solution: Sessions and cookies!
passport.serializeUser((user, done) => {
done(null, user.id); // Store only user ID in session
});
// When user logs in:
// 1. LocalStrategy returns user object
// 2. serializeUser runs and stores user.id in session
// 3. Session data saved, cookie sent to browser
passport.deserializeUser(async (id, done) => {
try {
const { rows } = await pool.query(
"SELECT * FROM users WHERE id = $1",
[id]
);
const user = rows[0];
done(null, user); // Attaches user to req.user
} catch(err) {
done(err);
}
});
// On subsequent requests:
// 1. Browser sends cookie with session ID
// 2. Server reads session, gets user ID
// 3. deserializeUser runs, queries database for user
// 4. User object attached to req.user
// 5. You can access req.user in your routes!
User submits credentials → LocalStrategy verifies username/password → Returns user object → serializeUser stores user.id in session → Session cookie sent to browser
Browser sends session cookie → Server reads session ID from cookie → Server retrieves user ID from session → deserializeUser queries database for user → User object attached to req.user → Route handler has access to req.user
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Home</title>
</head>
<body>
<h1>please log in</h1>
<form action="/log-in" method="POST">
<label for="username">Username</label>
<input id="username" name="username"
placeholder="username" type="text" />
<label for="password">Password</label>
<input id="password" name="password" type="password" />
<button type="submit">Log In</button>
</form>
</body>
</html>
app.post(
"/log-in",
passport.authenticate("local", {
successRedirect: "/",
failureRedirect: "/"
})
);
// This ONE line does SO MUCH:
// 1. Reads username/password from req.body
// 2. Runs LocalStrategy to verify credentials
// 3. Calls serializeUser to store user ID in session
// 4. Creates session cookie
// 5. Redirects based on success/failure
username and password fields in the form by default.
app.get("/", (req, res) => {
res.render("index", { user: req.user });
// req.user will be:
// - undefined if not logged in
// - user object if logged in
});
<% if (locals.user) {%>
<h1>WELCOME BACK <%= user.username %></h1>
<a href="/log-out">LOG OUT</a>
<% } else { %>
<h1>please log in</h1>
<!-- login form here -->
<%}%>
app.get("/log-out", (req, res, next) => {
req.logout((err) => {
if (err) {
return next(err);
}
res.redirect("/");
});
});
// req.logout() is provided by Passport
// It destroys the session and clears the cookie
// Add this middleware AFTER passport initialization
app.use((req, res, next) => {
res.locals.currentUser = req.user;
next();
});
// Now in ALL views, you can access currentUser
// WITHOUT passing it manually to each render()!
<% if (currentUser) { %>
<p>Welcome, <%= currentUser.username %>!</p>
<% } %>
NEVER store passwords in plain text!
If your database is compromised (SQL injection, hacked server, rogue employee, backup theft), attackers get all passwords. Users often reuse passwords across sites, so one breach compromises their email, bank, and more.
Hashing is a one-way function that transforms input into a fixed-size pseudo-random output.
password123 → hash function → $2a$10$N9qo8uLO...
mySecurePass → hash function → $2a$10$K5yP9xT2...
Key Properties:
Salt: Random data added to password before hashing
User A: password123 + salt_abc → hash_xyz
User B: password123 + salt_def → hash_uvw
Even if two users have the same password, their hashes are different! This prevents:
bcrypt automatically generates and stores salt within the hash!
npm install bcryptjs
# Note: There's also 'bcrypt' (written in C++)
# bcryptjs is pure JavaScript, easier to install
# bcrypt is faster, but bcryptjs works everywhere
const bcrypt = require("bcryptjs");
app.post("/sign-up", async (req, res, next) => {
try {
// Hash password with salt rounds = 10
const hashedPassword = await bcrypt.hash(req.body.password, 10);
await pool.query(
"INSERT INTO users (username, password) VALUES ($1, $2)",
[req.body.username, hashedPassword]
);
res.redirect("/");
} catch (err) {
return next(err);
}
});
const hashedPassword = await bcrypt.hash(password, 10);
// ^^
// salt rounds
Salt rounds = 10 means bcrypt will perform 2^10 = 1,024 iterations
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
const { rows } = await pool.query(
"SELECT * FROM users WHERE username = $1",
[username]
);
const user = rows[0];
if (!user) {
return done(null, false, { message: "Incorrect username" });
}
// Compare plain text password with hashed password
const match = await bcrypt.compare(password, user.password);
if (!match) {
return done(null, false, { message: "Incorrect password" });
}
return done(null, user);
} catch(err) {
return done(err);
}
})
);
await bcrypt.compare(plainTextPassword, hashedPasswordFromDB)
// Returns: true or false
What it does:
You don't need to store or manage salt separately—bcrypt handles it!
Build an exclusive clubhouse where members can write anonymous posts. Outsiders see posts but not authors. Members see who wrote what. Admins can delete posts.
-- Users table
CREATE TABLE users (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
is_member BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE
);
-- Messages table
CREATE TABLE messages (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
title VARCHAR(255) NOT NULL,
text TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
body("confirmPassword").custom((value, { req }) => {
return value === req.body.password;
})
<% if (currentUser && currentUser.is_member) { %>
<p>Author: <%= message.author %></p>
<% } %>
Tired of writing raw SQL? ORMs let you interact with databases using JavaScript objects and methods instead of SQL strings.
// Need to write queries for EVERY operation
await pool.query("SELECT * FROM books");
await pool.query("SELECT * FROM users");
await pool.query("INSERT INTO books (title, author) VALUES ($1, $2)", [title, author]);
await pool.query("UPDATE books SET title = $1 WHERE id = $2", [newTitle, id]);
// ... and so on for every table and operation
What columns does the books table have? You have to check the database or documentation.
Typos in column names, table names, or SQL syntax won't be caught until runtime.
Database structure is only in the database. To understand tables and relations, you must log into the database or check separate documentation.
When database structure changes, you write migration scripts by hand. Errors can corrupt data or break production.
Every project needs CRUD operations. You keep rewriting the same patterns: getAll, getById, create, update, delete...
Prisma is a next-generation ORM for Node.js and TypeScript (but works with JavaScript too!)
Why Prisma? Popular, well-documented, active community, works with PostgreSQL, MySQL, SQLite, MongoDB, and more.
// prisma/schema.prisma
model Message {
id Int @id @default(autoincrement())
content String @db.VarChar(255)
createdAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
authorId Int
}
model User {
id Int @id @default(autoincrement())
username String @unique
messages Message[]
}
model Message {
id Int @id @default(autoincrement())
// ^^^ ^^^
// type attributes
content String @db.VarChar(255)
// ^^^^^^^^^^^^^^^^
// database-specific type
createdAt DateTime @default(now())
// ^^^^^^^^^^^^^^
// default value
author User @relation(fields: [authorId], references: [id])
// ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// type relation definition
authorId Int // Foreign key
}
// Install Prisma Client
npm install @prisma/client
// Generate client from schema
npx prisma generate
// Use the client
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Create a message
await prisma.message.create({
data: {
content: 'Hello, world!',
authorId: 1
}
});
// Get all messages
const messages = await prisma.message.findMany();
// CREATE
const newUser = await prisma.user.create({
data: { username: 'alice', email: 'alice@example.com' }
});
// READ (single)
const user = await prisma.user.findUnique({
where: { id: 1 }
});
// READ (multiple with filter)
const users = await prisma.user.findMany({
where: { username: { contains: 'bob' } }
});
// UPDATE
const updated = await prisma.user.update({
where: { id: 1 },
data: { username: 'alice_updated' }
});
// DELETE
await prisma.user.delete({
where: { id: 1 }
});
// Include related data
const userWithMessages = await prisma.user.findUnique({
where: { id: 1 },
include: { messages: true } // Include all user's messages
});
// Filtering and sorting
const recentMessages = await prisma.message.findMany({
where: {
createdAt: { gte: new Date('2025-01-01') }
},
orderBy: { createdAt: 'desc' },
take: 10 // Limit to 10 results
});
// Pagination
const page2 = await prisma.message.findMany({
skip: 10, // Skip first 10
take: 10 // Take next 10
});
// Sometimes you need raw SQL for complex queries
const result = await prisma.$queryRaw`
SELECT u.username, COUNT(m.id) as message_count
FROM users u
LEFT JOIN messages m ON u.id = m.user_id
GROUP BY u.id
HAVING COUNT(m.id) > 5
`;
// Execute raw SQL (for INSERT, UPDATE, DELETE)
await prisma.$executeRaw`
UPDATE users SET last_login = NOW() WHERE id = ${userId}
`;
# Create a migration (after changing schema)
npx prisma migrate dev --name add_user_email
# Apply migrations to production
npx prisma migrate deploy
# Reset database (development only!)
npx prisma migrate reset
How it works:
prisma migrate devprisma/migrations/ folder⚠️ Prisma recently moved to TypeScript-first. To use with JavaScript, follow these modifications:
typescript, tsx, @types/node)--generator-provider prisma-client-js when initializing.js file extensions instead of .ts.js extensions to import statements# Initialize Prisma with JavaScript
npx prisma init --datasource-provider postgresql \
--output ../generated/prisma \
--generator-provider prisma-client-js
# Create lib/prisma.js (not .ts!)
# File: lib/prisma.js
import { PrismaClient } from '../generated/prisma/client.js';
// ^^^^ add .js extension
export const prisma = new PrismaClient();
# In your routes (script.js)
import { prisma } from './lib/prisma.js'; // Add .js extension
# Run scripts
node script.js // Not tsx or ts-node
Identity Columns:
PostgreSQL recommends IDENTITY columns (SQL standard) over SERIAL types. Prisma doesn't support IDENTITY and uses SERIAL instead.
// In Prisma schema:
id Int @id @default(autoincrement())
// Creates in PostgreSQL:
id SERIAL PRIMARY KEY
// PostgreSQL prefers:
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
This won't affect your curriculum projects, but good to be aware of!
Modern web development separates frontend and backend. APIs allow them to communicate using JSON instead of HTML.
Traditional: One server handles database, business logic, AND view templates
Modern (Jamstack): Backend server (API) + Separate frontend(s)
// Instead of res.render() or res.send(html)
app.get("/api/posts", (req, res) => {
res.json([
{ id: 1, title: "Hello World", author: "Alice" },
{ id: 2, title: "API Basics", author: "Bob" }
]);
});
// Frontend fetches this:
fetch("/api/posts")
.then(res => res.json())
.then(posts => {
// Display posts in React, Vue, vanilla JS, etc.
});
res.json() instead of res.render(). Your Express app is now an API!
REST is an architectural style for designing APIs. It's a set of conventions for organizing API endpoints.
Following REST makes your API intuitive and maintainable!
| HTTP Verb | CRUD | Action | Example |
|---|---|---|---|
POST |
Create | Create new resource | POST /posts |
GET |
Read | Retrieve resource(s) | GET /posts/:id |
PUT |
Update | Update entire resource | PUT /posts/:id |
PATCH |
Update | Update partial resource | PATCH /posts/:id |
DELETE |
Delete | Delete resource | DELETE /posts/:id |
GET /posts → Get all posts
GET /posts/:id → Get single post
POST /posts → Create new post
PUT /posts/:id → Update post
DELETE /posts/:id → Delete post
GET /posts/:id/comments → Get comments for post
POST /posts/:id/comments → Add comment to post
DELETE /posts/:id/comments/:cid → Delete specific comment
GET /getAllPosts
GET /getPostById?id=123
POST /createNewPost
POST /savePostInDatabase
const express = require("express");
const app = express();
app.use(express.json()); // Parse JSON bodies
// GET all posts
app.get("/api/posts", async (req, res) => {
const posts = await prisma.post.findMany();
res.json(posts);
});
// GET single post
app.get("/api/posts/:id", async (req, res) => {
const post = await prisma.post.findUnique({
where: { id: parseInt(req.params.id) }
});
res.json(post);
});
// CREATE new post
app.post("/api/posts", async (req, res) => {
const newPost = await prisma.post.create({
data: req.body
});
res.status(201).json(newPost);
});
Same-Origin Policy: Browsers block requests from one origin to another for security.
Frontend at: https://mywebsite.com
API at: https://api.mywebsite.com
Browser says: "Different origins! BLOCKED!"
Origins are different if:
# Install CORS middleware
npm install cors
const cors = require("cors");
// Allow ALL origins (development)
app.use(cors());
// Or allow specific origin (production)
app.use(cors({
origin: "https://mywebsite.com"
}));
// Or multiple origins
app.use(cors({
origin: ["https://mywebsite.com", "https://app.mywebsite.com"]
}));
CORS middleware adds headers to responses:
Access-Control-Allow-Origin: https://mywebsite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
These headers tell the browser: "It's OK! I allow this origin to access me."
res.json() instead of res.render()When frontend and backend are separate, we can't use session cookies the same way. Enter JWT tokens—a secure way to authenticate API requests.
Authorization headereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywiaWF0IjoxNjQwOTk1MjAwfQ.3c5_Xd8kF2pN7vQ8sT1uZ9yW0aB6cE4fG7hI8jK9lM2
Three parts separated by dots:
eyJhbGciOi... (algorithm & type)eyJ1c2VySW... (user data)3c5_Xd8kF... (cryptographic signature)Base64 encoded (not encrypted!) but signed to prevent tampering
// Header
{
"alg": "HS256", // Algorithm: HMAC SHA256
"typ": "JWT" // Type: JSON Web Token
}
// Payload (your data)
{
"userId": 123,
"username": "alice",
"iat": 1640995200, // Issued At timestamp
"exp": 1641081600 // Expiration timestamp
}
// Signature = hash(header + payload + secret)
// Ensures nobody tampered with the token
npm install jsonwebtoken
const jwt = require("jsonwebtoken");
// Login route
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
// Verify credentials (check database, bcrypt.compare, etc.)
const user = await verifyCredentials(username, password);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Create JWT
const token = jwt.sign(
{ userId: user.id, username: user.username }, // Payload
process.env.JWT_SECRET, // Secret key
{ expiresIn: "1h" } // Expires in 1 hour
);
res.json({ token });
});
const jwt = require("jsonwebtoken");
// Middleware to verify JWT
function authenticateToken(req, res, next) {
// Get token from Authorization header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: "No token provided" });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "Invalid token" });
}
req.user = user; // Attach decoded payload to req.user
next();
});
}
// Protected route
app.get("/api/protected", authenticateToken, (req, res) => {
res.json({ message: "You are authenticated!", user: req.user });
});
// Client-side (fetch API)
const token = localStorage.getItem('token');
fetch('/api/protected', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(data => console.log(data));
Authorization Header Format:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"Bearer" is the authentication scheme. The token follows after a space.
// Create token with expiration
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: "1h" } // Options: "1h", "7d", "30m", "2 days"
);
// When token expires, jwt.verify() will fail
jwt.verify(token, secret, (err, user) => {
if (err) {
// Could be expired OR invalid signature
return res.status(403).json({ error: "Token expired or invalid" });
}
// Token is valid
});
The Problem: Short-lived tokens (1 hour) mean users must re-login frequently.
The Solution: Two tokens!
When access token expires, client uses refresh token to get a new one. No re-login needed!
(This is more advanced—focus on basic JWTs for now)
npm install passport-jwt
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET
},
async (payload, done) => {
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId }
});
if (user) {
return done(null, user);
}
return done(null, false);
} catch (err) {
return done(err, false);
}
}
));
// Protected route
app.get("/api/protected",
passport.authenticate('jwt', { session: false }),
(req, res) => {
res.json({ user: req.user });
}
);
Authorization: Bearer TOKEN headerjwt.sign() to create tokensjwt.verify() to validate tokensTwo major projects to practice everything we've learned: authentication, file handling, APIs, and more!
https://yourapp.com/share/c758c495-0705-44c6-8bab-6635fd12cf81
# Core dependencies
npm install express prisma @prisma/client passport passport-local \
express-session bcryptjs ejs
# File upload
npm install multer
# Cloud storage (choose one)
npm install cloudinary # Cloudinary
# OR
npm install @supabase/supabase-js # Supabase
# Session store
npm install @quixo3/prisma-session-store
model User {
id Int @id @default(autoincrement())
username String @unique
password String // Hashed with bcrypt
isAuthor Boolean @default(false)
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content Text
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
authorId Int
comments Comment[]
}
model Comment {
id Int @id @default(autoincrement())
text String
username String? // Optional username
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id])
postId Int
}
Before building frontends, test your API endpoints:
Test all CRUD operations, authentication, and error handling before writing frontend code!
Backend API: Deploy to Railway, Render, or similar (from Deployment lesson)
Public Frontend: Deploy to Netlify, Vercel, GitHub Pages, or Cloudflare Pages
Admin Frontend: Same options as public frontend
Don't forget: Configure CORS to allow your frontend domains!
This is our last content lecture! Let's talk about turning your projects into portfolio pieces and getting noticed by employers.
Many topics we've covered come from The Odin Project curriculum. I encourage you to complete the full program:
Why? It's free, comprehensive, project-based, and respected in the industry. Employers recognize Odin Project graduates.
đź”— https://www.theodinproject.com
From personal experience: Most headhunter pings I get come from:
Resumes and LinkedIn are great, but when people want to see what you can do, they look at your portfolio.
A deployed project shows: technical skills, follow-through, real-world problem solving, and that you care about your craft.
Just as the Valkyries choose warriors worthy of Valhalla, your authentication systems choose users worthy of access. You've learned to guard the gates, verify identities, and grant permissions.
These aren't just class projects—they're the foundation of modern web development. Every major application uses these patterns.
Build. Deploy. Share. Repeat. Your portfolio is your greatest asset. Make it legendary.
Let's discuss: