Introduction to Tailwind CSS Authentication
Authentication is one of the most critical features in modern web applications. Whether you're building a simple login form or a complex multi-provider OAuth system, the user interface plays a significant role in the overall user experience. Tailwind CSS, with its utility-first approach, makes it incredibly efficient to build polished, responsive, and accessible authentication interfaces. In this tutorial, we'll explore how to build authentication UIs with Tailwind CSS and integrate them with JWT, session-based, and OAuth authentication strategies.
Why Authentication UI Matters
Authentication is the gateway to your application. A poorly designed auth flow can frustrate users, increase abandonment rates, and even introduce security vulnerabilities. Tailwind CSS helps address these concerns by allowing you to rapidly prototype and build consistent, accessible, and responsive auth components without writing custom CSS. Combined with proper backend authentication strategies like JWT, sessions, and OAuth, you can create a secure and seamless experience for your users.
Key Benefits of Using Tailwind for Auth UIs
- Rapid development: Build complete login, registration, and password reset forms in minutes.
- Consistency: Utility classes ensure visual consistency across all auth-related screens.
- Responsiveness: Built-in responsive utilities make forms look great on any device.
- Accessibility: Easy to add focus states, ARIA attributes, and semantic markup.
- Customization: Extend the default theme to match your brand identity.
Setting Up the Project
Before we dive into building authentication components, let's set up a project with Tailwind CSS. We'll use a simple HTML project with Vite for this tutorial, but the concepts apply to React, Vue, Next.js, or any other framework.
First, create a new project directory and initialize it:
mkdir tailwind-auth-tutorial
cd tailwind-auth-tutorial
npm init -y
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Configure your tailwind.config.js file to scan your HTML and JavaScript files:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./*.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
100: "#dbeafe",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
},
},
},
},
plugins: [],
};
Create a CSS file at ./src/input.css with the Tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;
Build the CSS:
npx tailwindcss -i ./src/input.css -o ./dist/output.css --watch
Now you're ready to build authentication components with Tailwind CSS.
Building Auth UI Components with Tailwind
Let's start by building a clean, responsive login form. This form will serve as the foundation for all three authentication strategies we'll cover.
Login Form Component
Create an index.html file with the following login form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login | Tailwind Auth Tutorial</title>
<link rel="stylesheet" href="https://agentechip.com/dist/output.css" />
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Welcome back</h1>
<p class="text-gray-500 mt-2">Sign in to your account</p>
</div>
<form id="loginForm" class="space-y-5">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email address
</label>
<input
type="email"
id="email"
name="email"
required
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition"
placeholder="you@example.com"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition"
placeholder="••••••••"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" class="rounded border-gray-300 text-brand-600 focus:ring-brand-500" />
Remember me
</label>
<a href="#" class="text-sm text-brand-600 hover:text-brand-700 font-medium">
Forgot password?
</a>
</div>
<button
type="submit"
class="w-full bg-brand-600 hover:bg-brand-700 text-white font-semibold py-2.5 rounded-lg transition focus:ring-2 focus:ring-offset-2 focus:ring-brand-500"
>
Sign in
</button>
</form>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white px-4 text-gray-400">Or continue with</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<button class="flex items-center justify-center gap-2 border border-gray-300 rounded-lg py-2.5 hover:bg-gray-50 transition">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</button>
<button class="flex items-center justify-center gap-2 border border-gray-300 rounded-lg py-2.5 hover:bg-gray-50 transition">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
</button>
</div>
<p class="text-center text-sm text-gray-500 mt-6">
Don't have an account?
<a href="#" class="text-brand-600 hover:text-brand-700 font-medium">Sign up</a>
</p>
</div>
<script src="https://agentechip.com/src/auth.js"></script>
</body>
</html>
This form includes email and password fields, a remember me checkbox, a forgot password link, and OAuth provider buttons. The Tailwind classes provide a clean, modern look with proper focus states and hover effects.
Registration Form Component
Let's also create a registration form. You can place this in a separate register.html file:
<div class="max-w-md w-full bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Create account</h1>
<p class="text-gray-500 mt-2">Get started in just a few seconds</p>
</div>
<form id="registerForm" class="space-y-5">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
type="text"
id="name"
name="name"
required
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition"
placeholder="John Doe"
/>
</div>
<div>
<label for="reg-email" class="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
id="reg-email"
name="email"
required
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition"
placeholder="you@example.com"
/>
</div>
<div>
<label for="reg-password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
id="reg-password"
name="password"
required
minlength="8"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none transition"
placeholder="At least 8 characters"
/>
<p class="text-xs text-gray-400 mt-1">Use a mix of letters, numbers, and symbols</p>
</div>
<label class="flex items-start gap-2 text-sm text-gray-600">
<input type="checkbox" required class="mt-1 rounded border-gray-300 text-brand-600 focus:ring-brand-500" />
<span>I agree to the <a href="#" class="text-brand-600 hover:text-brand-700">Terms of Service</a> and <a href="#" class="text-brand-600 hover:text-brand-700">Privacy Policy</a></span>
</label>
<button
type="submit"
class="w-full bg-brand-600 hover:bg-brand-700 text-white font-semibold py-2.5 rounded-lg transition focus:ring-2 focus:ring-offset-2 focus:ring-brand-500"
>
Create account
</button>
</form>
</div>
JWT Authentication
JSON Web Tokens (JWT) are a popular stateless authentication mechanism. When a user logs in, the server issues a signed token that the client stores and sends with subsequent requests. Let's build a complete JWT authentication flow.
How JWT Works
A JWT consists of three parts: a header, a payload, and a signature, separated by dots. The header specifies the token type and signing algorithm. The payload contains claims like user ID and expiration time. The signature ensures the token hasn't been tampered with.
Backend: Login Endpoint
Here's a Node.js Express endpoint that issues JWT tokens on successful login:
// server.js
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
const JWT_SECRET = "your-super-secret-key-change-in-production";
const JWT_EXPIRES_IN = "1h";
// Mock user database
const users = [
{
id: 1,
email: "user@example.com",
// Hash of "password123"
passwordHash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
},
];
app.post("/api/auth/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({
token,
user: { id: user.id, email: user.email },
});
});
app.listen(3000, () => console.log("Server running on port 3000"));
Frontend: JWT Login Handler
Now let's handle the login form submission on the client side. We'll store the JWT in localStorage and attach it to future requests:
// src/auth.js
const loginForm = document.getElementById("loginForm");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("email").value;
const password = document.getElementById("password").value;
const submitBtn = loginForm.querySelector('button[type="submit"]');
// Show loading state
submitBtn.disabled = true;
submitBtn.textContent = "Signing in...";
try {
const response = await fetch("http://localhost:3000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Login failed");
}
// Store the JWT token
localStorage.setItem("authToken", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
// Redirect to dashboard
window.location.href = "/dashboard.html";
} catch (error) {
showError(error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Sign in";
}
});
}
function showError(message) {
const existing = document.querySelector(".error-banner");
if (existing) existing.remove();
const banner = document.createElement("div");
banner.className =
"error-banner bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm mb-4";
banner.textContent = message;
const form = document.getElementById("loginForm");
form.parentNode.insertBefore(banner, form);
}
// Helper to get the auth token
function getAuthToken() {
return localStorage.getItem("authToken");
}
// Helper to make authenticated requests
async function authFetch(url, options = {}) {
const token = getAuthToken();
if (!token) {
window.location.href = "/index.html";
return;
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
localStorage.removeItem("authToken");
localStorage.removeItem("user");
window.location.href = "/index.html";
return;
}
return response;
}
Protected Dashboard Page
Here's a dashboard page that requires authentication. It uses the authFetch helper to include the JWT in requests:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard</title>
<link rel="stylesheet" href="https://agentechip.com/dist/output.css" />
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<h1 class="text-xl font-bold text-gray-900">Dashboard</h1>
<button
id="logoutBtn"
class="text-sm text-gray-600 hover:text-gray-900 font-medium px-4 py-2 rounded-lg hover:bg-gray-100 transition"
>
Logout
</button>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Welcome, <span id="userName">User</span>!</h2>
<p class="text-gray-500">You are successfully authenticated with JWT.</p>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-brand-50 rounded-xl p-5">
<p class="text-sm text-brand-700 font-medium">Total Projects</p>
<p class="text-3xl font-bold text-brand-700 mt-1">12</p>
</div>
<div class="bg-green-50 rounded-xl p-5">
<p class="text-sm text-green-700 font-medium">Active Tasks</p>
<p class="text-3xl font-bold text-green-700 mt-1">5</p>
</div>
<div class="bg-purple-50 rounded-xl p-5">
<p class="text-sm text-purple-700 font-medium">Completed</p>
<p class="text-3xl font-bold text-purple-700 mt-1">28</p>
</div>
</div>
</div>
</main>
<script src="https://agentechip.com/src/auth.js"></script>
<script>
// Check authentication on page load
const token = localStorage.getItem("authToken");
const user = JSON.parse(localStorage.getItem("user") || "null");
if (!token || !user) {
window.location.href = "/index.html";
} else {
document.getElementById("userName").textContent = user.email;
}
document.getElementById("logoutBtn").addEventListener("click", () => {
localStorage.removeItem("authToken");
localStorage.removeItem("user");
window.location.href = "/index.html";
});
</script>
</body>
</html>
Backend: Protected Route with JWT Verification
On the server side, create middleware to verify JWT tokens on protected routes:
// middleware/auth.js
const jwt = require("jsonwebtoken");
const JWT_SECRET = "your-super-secret-key-change-in-production";
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "Invalid or expired token" });
}
req.user = user;
next();
});
}
module.exports = { authenticateToken };
// In server.js, protect routes like this:
const { authenticateToken } = require("./middleware/auth");
app.get("/api/profile", authenticateToken, (req, res) => {
res.json({
user: {
id: req.user.userId,
email: req.user.email,
},
});
});
JWT Token Refresh
Since JWTs expire, you should implement a token refresh mechanism. Here's how to add a refresh endpoint:
// server.js - Add refresh token endpoint
app.post("/api/auth/refresh", authenticateToken, (req, res) => {
const newToken = jwt.sign(
{ userId: req.user.userId, email: req.user.email },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({ token: newToken });
});
// src/auth.js - Auto-refresh before expiry
function setupTokenRefresh() {
const token = getAuthToken();
if (!token) return;
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const expiresIn = payload.exp * 1000 - Date.now();
const refreshIn = expiresIn - 60000; // Refresh 1 minute before expiry
if (refreshIn <= 0) {
refreshToken();
} else {
setTimeout(refreshToken, refreshIn);
}
} catch (e) {
console.error("Failed to parse token", e);
}
}
async function refreshToken() {
try {
const response = await authFetch("http://localhost:3000/api/auth/refresh", {
method: "POST",
});
const data = await response.json();
localStorage.setItem("authToken", data.token);
setupTokenRefresh();
} catch (error) {
console.error("Token refresh failed", error);
localStorage.removeItem("authToken");
localStorage.removeItem("user");
window.location.href = "/index.html";
}
}
Session-Based Authentication
Session-based authentication is a stateful approach where the server stores session data and sends a session ID to the client via a cookie. This is the traditional approach and is still widely used, especially with server-rendered applications.
How Sessions Work
When a user logs in, the server creates a session, stores it in memory or a session store like Redis, and sends the session ID to the client as an HTTP-only cookie. On subsequent requests, the browser automatically includes the cookie, and the server looks up the session to identify the user.
Backend: Session Login with Express
// server-session.js
const express = require("express");
const session = require("express-session");
const bcrypt = require("bcryptjs");
const cors = require("cors");
const app = express();
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.use(express.json());
app.use(
session({
secret: "your-session-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false, // Set to true in production with HTTPS
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
})
);
const users = [
{
id: 1,
email: "user@example.com",
passwordHash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
},
];
// Login endpoint
app.post("/api/session/login", async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.id;
req.session.email = user.email;
res.json({ user: { id: user.id, email: user.email } });
});
// Protected route
app.get("/api/session/profile", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Not authenticated" });
}
res.json({
user: {
id: req.session.userId,
email: req.session.email,
},
});
});
// Logout endpoint
app.post("/api/session/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.clearCookie("connect.sid");
res.json({ message: "Logged out successfully" });
});
});
app.listen(3001, () => console.log("Session server running on port 3001"));
Frontend: Session-Based Login
With session-based auth, the browser handles cookies automatically. You just need to ensure credentials: "include" is set on fetch requests:
// src/session-auth.js
const sessionLoginForm = document.getElementById("loginForm");
if (sessionLoginForm) {
sessionLoginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("email").value;
const password = document.getElementById("password").value;
const submitBtn = sessionLoginForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = "Signing in...";
try {
const response = await fetch("http://localhost:3001/api/session/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // Important: include cookies
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Login failed");
}
// No need to store anything in localStorage - cookie is set automatically
window.location.href = "/dashboard.html";
} catch (error) {
showError(error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Sign in";
}
});
}
// Check session status on protected pages
async function checkSession() {
try {
const response = await fetch("http://localhost:3001/api/session/profile", {
credentials: "include",
});
if (!response.ok) {
window.location.href = "/index.html";
return null;
}
return await response.json();
} catch (error) {
window.location.href = "/index.html";
return null;
}
}
// Logout
async function sessionLogout() {
await fetch("http://localhost:3001/api/session/logout", {
method: "POST",
credentials: "include",
});
window.location.href = "/index.html";
}
Session Dashboard Page
<script>
// On dashboard load, verify session
(async () => {
const data = await checkSession();
if (data) {
document.getElementById("userName").textContent = data.user.email;
}
})();
document.getElementById("logoutBtn").addEventListener("click", sessionLogout);
</script>
OAuth Integration
OAuth allows users to authenticate using third-party providers like Google, GitHub, Facebook, or Twitter. This provides a convenient sign-in experience and reduces the need for users to remember yet another password. Let's implement OAuth with Google and GitHub using Passport.js.
How OAuth Works
OAuth 2.0 follows an authorization code flow. The user is redirected to the provider's login page, grants permission, and is redirected back to your application with an authorization code. Your server exchanges this code for an access token and user profile information.
Backend: OAuth with Passport.js
First, install the required packages:
npm install passport passport-google-oauth20 passport-github2 express-session
Then configure Passport strategies:
// server-oauth.js
const express = require("express");
const session = require("express-session");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const GitHubStrategy = require("passport-github2").Strategy;
const jwt = require("jsonwebtoken");
const app = express();
const JWT_SECRET = "your-jwt-secret";
const CLIENT_URL = "http://localhost:5173";
app.use(
session({
secret: "oauth-session-secret",
resave: false,
saveUninitialized: false,
})
);
app.use(passport.initialize());
app.use(passport.session());
// Serialize and deserialize user for session
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
// Google OAuth Strategy
passport.use(
new GoogleStrategy(
{
clientID: "your-google-client-id",
clientSecret: "your-google-client-secret",
callbackURL: "/api/auth/google/callback",
},
(accessToken, refreshToken, profile, done) => {
// In production, find or create user in your database
const user = {
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
provider: "google",
};
return done(null, user);
}
)
);
// GitHub OAuth Strategy
passport.use(
new GitHubStrategy(
{
clientID: "