Build a URL Shortener: Authentication & User Management

Up to this point, our URL shortener is a free-for-all. Anyone can shorten URLs, and there's no concept of ownership. That's fine for a toy project, but real URL shorteners need to answer: who created this link?
In this post, we'll add user authentication and URL ownership. Users will register, log in, get JWT tokens, and manage their own URLs. We'll also add API keys for programmatic access — because not everyone wants to click buttons in a dashboard.
Time commitment: 2–3 hours
Prerequisites: Phase 5: Caching with Redis
What we'll build in this post:
✅ User registration with email and bcrypt password hashing
✅ Login endpoint that issues JWT access + refresh tokens
✅ Auth middleware to protect routes
✅ User-scoped URL management (list, update, delete my URLs)
✅ API key generation for programmatic access
✅ Token refresh flow for seamless session management
Authentication Architecture
Before writing code, let's understand the token strategy:
Why Access + Refresh Tokens?
A single long-lived token is a security risk — if stolen, an attacker has access for days or weeks. Short-lived tokens are safer but annoying (users must log in every 15 minutes).
The two-token strategy solves this:
| Token | Lifetime | Purpose | Stored Where |
|---|---|---|---|
| Access token | 15 minutes | Authenticate API requests | Client memory |
| Refresh token | 7 days | Get new access tokens | Database + client |
If an access token is stolen, the damage window is 15 minutes. If a refresh token is stolen, we can revoke it from the database.
Prisma Schema — Users and Refresh Tokens
First, let's extend our Prisma schema with user-related models:
// prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
password String // bcrypt hash
name String?
apiKey String? @unique @map("api_key")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
urls Url[]
refreshTokens RefreshToken[]
@@map("users")
}
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
@@map("refresh_tokens")
}And update the existing Url model to support optional ownership:
model Url {
id String @id @default(uuid())
shortCode String @unique @map("short_code")
originalUrl String @map("original_url")
clickCount Int @default(0) @map("click_count")
userId String? @map("user_id") // null = anonymous
customAlias Boolean @default(false) @map("custom_alias")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([shortCode])
@@index([userId])
@@map("urls")
}Key design decisions:
userIdis optional — anonymous users can still shorten URLs (backward compatible)RefreshTokentable — stores refresh tokens so we can revoke themapiKeyon User — single API key per user, generated on demand- Cascade delete on refresh tokens — when a user is deleted, all their tokens are deleted too
- SetNull on URLs — when a user is deleted, their URLs remain but lose ownership
Run the migration:
npx prisma migrate dev --name add-users-and-authInstall Dependencies
npm install bcrypt jsonwebtoken
npm install -D @types/bcrypt @types/jsonwebtoken- bcrypt — industry-standard password hashing with salt rounds
- jsonwebtoken — JWT creation and verification
Environment Variables
Add to .env:
# Auth
JWT_ACCESS_SECRET=your-access-token-secret-change-in-production
JWT_REFRESH_SECRET=your-refresh-token-secret-change-in-production
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7dUpdate src/config/env.ts:
// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
export const env = {
port: parseInt(process.env.PORT || '3000', 10),
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
nodeEnv: process.env.NODE_ENV || 'development',
databaseUrl: process.env.DATABASE_URL || '',
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
// Auth
jwtAccessSecret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret',
jwtAccessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
} as const;Auth Types
Define the types we'll use across the auth system:
// src/types/auth.ts
export interface RegisterRequest {
email: string;
password: string;
name?: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface TokenPayload {
userId: string;
email: string;
}
export interface AuthenticatedRequest {
userId: string;
email: string;
}
export interface UserResponse {
id: string;
email: string;
name: string | null;
createdAt: Date;
}
export interface ApiKeyResponse {
apiKey: string;
}Auth Service
The auth service handles registration, login, token management, and API keys. This is the core of our authentication system:
// src/services/authService.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
import {
RegisterRequest,
LoginRequest,
AuthTokens,
TokenPayload,
UserResponse,
} from '../types/auth';
const prisma = new PrismaClient();
const SALT_ROUNDS = 12;
export class AuthService {
// ─── Registration ──────────────────────────────────────
async register(data: RegisterRequest): Promise<UserResponse> {
const { email, password, name } = data;
// Check if email already exists
const existing = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existing) {
throw new AppError('Email is already registered', 409);
}
// Validate password strength
this.validatePassword(password);
// Hash password
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
// Create user
const user = await prisma.user.create({
data: {
email: email.toLowerCase(),
password: hashedPassword,
name: name || null,
},
});
return this.toUserResponse(user);
}
// ─── Login ──────────────────────────────────────────────
async login(data: LoginRequest): Promise<AuthTokens> {
const { email, password } = data;
// Find user
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
// Use same message for missing user and wrong password
// to prevent email enumeration
throw new AppError('Invalid email or password', 401);
}
if (!user.isActive) {
throw new AppError('Account is deactivated', 403);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new AppError('Invalid email or password', 401);
}
// Generate tokens
return this.generateTokens(user.id, user.email);
}
// ─── Token Management ──────────────────────────────────
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string }> {
// Find the refresh token in database
const storedToken = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true },
});
if (!storedToken) {
throw new AppError('Invalid refresh token', 401);
}
// Check if token is expired
if (storedToken.expiresAt < new Date()) {
// Clean up expired token
await prisma.refreshToken.delete({
where: { id: storedToken.id },
});
throw new AppError('Refresh token has expired', 401);
}
// Verify the JWT signature
try {
jwt.verify(refreshToken, env.jwtRefreshSecret);
} catch {
await prisma.refreshToken.delete({
where: { id: storedToken.id },
});
throw new AppError('Invalid refresh token', 401);
}
// Generate new access token
const accessToken = this.generateAccessToken(
storedToken.user.id,
storedToken.user.email
);
return { accessToken };
}
async logout(refreshToken: string): Promise<void> {
await prisma.refreshToken.deleteMany({
where: { token: refreshToken },
});
}
async logoutAllDevices(userId: string): Promise<void> {
await prisma.refreshToken.deleteMany({
where: { userId },
});
}
// ─── API Keys ──────────────────────────────────────────
async generateApiKey(userId: string): Promise<{ apiKey: string }> {
const apiKey = `usk_${crypto.randomBytes(32).toString('hex')}`;
await prisma.user.update({
where: { id: userId },
data: { apiKey },
});
return { apiKey };
}
async revokeApiKey(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { apiKey: null },
});
}
async validateApiKey(apiKey: string): Promise<TokenPayload | null> {
const user = await prisma.user.findUnique({
where: { apiKey },
});
if (!user || !user.isActive) {
return null;
}
return { userId: user.id, email: user.email };
}
// ─── User Profile ──────────────────────────────────────
async getProfile(userId: string): Promise<UserResponse> {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new AppError('User not found', 404);
}
return this.toUserResponse(user);
}
// ─── Private Helpers ───────────────────────────────────
private validatePassword(password: string): void {
if (password.length < 8) {
throw new AppError('Password must be at least 8 characters', 400);
}
if (password.length > 128) {
throw new AppError('Password must be at most 128 characters', 400);
}
if (!/[A-Z]/.test(password)) {
throw new AppError('Password must contain at least one uppercase letter', 400);
}
if (!/[a-z]/.test(password)) {
throw new AppError('Password must contain at least one lowercase letter', 400);
}
if (!/[0-9]/.test(password)) {
throw new AppError('Password must contain at least one number', 400);
}
}
private async generateTokens(userId: string, email: string): Promise<AuthTokens> {
const accessToken = this.generateAccessToken(userId, email);
const refreshToken = this.generateRefreshToken(userId, email);
// Calculate refresh token expiry
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days
// Store refresh token in database
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId,
expiresAt,
},
});
return { accessToken, refreshToken };
}
private generateAccessToken(userId: string, email: string): string {
const payload: TokenPayload = { userId, email };
return jwt.sign(payload, env.jwtAccessSecret, {
expiresIn: env.jwtAccessExpiry,
});
}
private generateRefreshToken(userId: string, email: string): string {
const payload: TokenPayload = { userId, email };
return jwt.sign(payload, env.jwtRefreshSecret, {
expiresIn: env.jwtRefreshExpiry,
});
}
private toUserResponse(user: {
id: string;
email: string;
name: string | null;
createdAt: Date;
}): UserResponse {
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt,
};
}
}Key Security Decisions
12 salt rounds for bcrypt — each round doubles the hashing time. 12 rounds takes ~250ms, which is slow enough to deter brute-force attacks but fast enough that users don't notice.
| Salt Rounds | Time per Hash | Purpose |
|---|---|---|
| 8 | ~40ms | Too fast, weak protection |
| 10 | ~100ms | Minimum for production |
| 12 | ~250ms | Good balance |
| 14 | ~1s | Paranoid mode |
Same error message for wrong email and wrong password — "Invalid email or password" prevents attackers from discovering which emails exist in your system (email enumeration).
API key format — usk_ prefix makes keys identifiable. If a key leaks in a log, you immediately know what it is. The 32-byte hex gives 256 bits of entropy.
Auth Middleware
The middleware extracts and verifies the JWT from the Authorization header, then attaches user info to the request:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { TokenPayload } from '../types/auth';
import { AuthService } from '../services/authService';
const authService = new AuthService();
// Extend Express Request to include user info
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function verifyToken(
req: Request,
res: Response,
next: NextFunction
): void {
// Check for Bearer token
const authHeader = req.headers.authorization;
// Check for API key
const apiKey = req.headers['x-api-key'] as string;
if (!authHeader && !apiKey) {
res.status(401).json({ error: 'Authentication required' });
return;
}
// Try API key first
if (apiKey) {
authService
.validateApiKey(apiKey)
.then((payload) => {
if (!payload) {
res.status(401).json({ error: 'Invalid API key' });
return;
}
req.user = payload;
next();
})
.catch(next);
return;
}
// Try Bearer token
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
res.status(401).json({
error: 'Invalid authorization format. Use: Bearer <token>',
});
return;
}
const token = parts[1];
try {
const payload = jwt.verify(token, env.jwtAccessSecret) as TokenPayload;
req.user = payload;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
res.status(401).json({
error: 'Access token has expired',
code: 'TOKEN_EXPIRED',
});
return;
}
res.status(401).json({ error: 'Invalid access token' });
return;
}
}
}
// Optional auth — attaches user if token present, but doesn't reject
export function optionalAuth(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
const apiKey = req.headers['x-api-key'] as string;
if (!authHeader && !apiKey) {
next();
return;
}
// Delegate to verifyToken, but catch 401s and continue
verifyToken(req, res, (err) => {
if (err) {
// Authentication failed, but that's ok — continue without user
next();
} else {
next();
}
});
}Two Authentication Modes
verifyToken — strict. Returns 401 if no valid token. Use on protected routes.
optionalAuth — lenient. Attaches user info if present, continues without it. Use on the URL shortening endpoint so authenticated users get URL ownership while anonymous users still work.
Why Support Both Bearer Token and API Key?
Bearer tokens are for interactive sessions (browser, mobile app). API keys are for automation (CI/CD, scripts, integrations). Supporting both in the same middleware keeps routes clean — the route doesn't care how you authenticated.
Auth Validation
Add request validation for registration and login:
// src/middleware/validateAuth.ts
import { Request, Response, NextFunction } from 'express';
export function validateRegister(
req: Request,
res: Response,
next: NextFunction
): void {
const { email, password } = req.body;
if (!email || typeof email !== 'string') {
res.status(400).json({ error: 'Email is required' });
return;
}
// Basic email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.trim())) {
res.status(400).json({ error: 'Invalid email format' });
return;
}
if (!password || typeof password !== 'string') {
res.status(400).json({ error: 'Password is required' });
return;
}
// Normalize email
req.body.email = email.trim().toLowerCase();
next();
}
export function validateLogin(
req: Request,
res: Response,
next: NextFunction
): void {
const { email, password } = req.body;
if (!email || typeof email !== 'string') {
res.status(400).json({ error: 'Email is required' });
return;
}
if (!password || typeof password !== 'string') {
res.status(400).json({ error: 'Password is required' });
return;
}
req.body.email = email.trim().toLowerCase();
next();
}
export function validateRefreshToken(
req: Request,
res: Response,
next: NextFunction
): void {
const { refreshToken } = req.body;
if (!refreshToken || typeof refreshToken !== 'string') {
res.status(400).json({ error: 'Refresh token is required' });
return;
}
next();
}Auth Routes
// src/routes/authRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/authService';
import { verifyToken } from '../middleware/auth';
import {
validateRegister,
validateLogin,
validateRefreshToken,
} from '../middleware/validateAuth';
const router = Router();
const authService = new AuthService();
// POST /api/auth/register
router.post(
'/register',
validateRegister,
async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await authService.register(req.body);
res.status(201).json({
message: 'Registration successful',
user,
});
} catch (error) {
next(error);
}
}
);
// POST /api/auth/login
router.post(
'/login',
validateLogin,
async (req: Request, res: Response, next: NextFunction) => {
try {
const tokens = await authService.login(req.body);
res.json({
message: 'Login successful',
...tokens,
});
} catch (error) {
next(error);
}
}
);
// POST /api/auth/refresh
router.post(
'/refresh',
validateRefreshToken,
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.refreshAccessToken(req.body.refreshToken);
res.json(result);
} catch (error) {
next(error);
}
}
);
// POST /api/auth/logout
router.post(
'/logout',
async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
next(error);
}
}
);
// POST /api/auth/logout-all — revoke all refresh tokens
router.post(
'/logout-all',
verifyToken,
async (req: Request, res: Response, next: NextFunction) => {
try {
await authService.logoutAllDevices(req.user!.userId);
res.json({ message: 'Logged out from all devices' });
} catch (error) {
next(error);
}
}
);
// GET /api/auth/profile
router.get(
'/profile',
verifyToken,
async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await authService.getProfile(req.user!.userId);
res.json(profile);
} catch (error) {
next(error);
}
}
);
// POST /api/auth/api-key — generate a new API key
router.post(
'/api-key',
verifyToken,
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.generateApiKey(req.user!.userId);
res.status(201).json({
message: 'API key generated. Store it securely — it cannot be retrieved later.',
...result,
});
} catch (error) {
next(error);
}
}
);
// DELETE /api/auth/api-key — revoke API key
router.delete(
'/api-key',
verifyToken,
async (req: Request, res: Response, next: NextFunction) => {
try {
await authService.revokeApiKey(req.user!.userId);
res.json({ message: 'API key revoked' });
} catch (error) {
next(error);
}
}
);
export default router;User-Scoped URL Management
Now the important part — letting users manage their own URLs. We need a service that handles URL CRUD operations scoped to the authenticated user:
// src/services/userUrlService.ts
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
const prisma = new PrismaClient();
export interface UserUrl {
id: string;
shortCode: string;
shortUrl: string;
originalUrl: string;
clickCount: number;
customAlias: boolean;
expiresAt: Date | null;
createdAt: Date;
}
export interface UpdateUrlRequest {
originalUrl?: string;
expiresAt?: string | null;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export class UserUrlService {
// ─── List User's URLs ──────────────────────────────────
async listUrls(
userId: string,
page: number = 1,
pageSize: number = 20,
search?: string
): Promise<PaginatedResult<UserUrl>> {
const skip = (page - 1) * pageSize;
const where: any = { userId };
if (search) {
where.OR = [
{ shortCode: { contains: search, mode: 'insensitive' } },
{ originalUrl: { contains: search, mode: 'insensitive' } },
];
}
const [urls, total] = await Promise.all([
prisma.url.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
prisma.url.count({ where }),
]);
return {
data: urls.map(this.toUserUrl),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
// ─── Get Single URL ────────────────────────────────────
async getUrl(shortCode: string, userId: string): Promise<UserUrl> {
const url = await prisma.url.findFirst({
where: { shortCode, userId },
});
if (!url) {
throw new AppError('URL not found or does not belong to you', 404);
}
return this.toUserUrl(url);
}
// ─── Update URL ────────────────────────────────────────
async updateUrl(
shortCode: string,
userId: string,
data: UpdateUrlRequest
): Promise<UserUrl> {
// Verify ownership
const existing = await prisma.url.findFirst({
where: { shortCode, userId },
});
if (!existing) {
throw new AppError('URL not found or does not belong to you', 404);
}
// Validate new URL if provided
if (data.originalUrl) {
try {
const parsed = new URL(data.originalUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new AppError('Only HTTP and HTTPS URLs are supported', 400);
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError('Invalid URL format', 400);
}
}
// Build update data
const updateData: any = {};
if (data.originalUrl) {
updateData.originalUrl = data.originalUrl;
}
if (data.expiresAt !== undefined) {
updateData.expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
}
const updated = await prisma.url.update({
where: { id: existing.id },
data: updateData,
});
return this.toUserUrl(updated);
}
// ─── Delete URL ────────────────────────────────────────
async deleteUrl(shortCode: string, userId: string): Promise<void> {
const existing = await prisma.url.findFirst({
where: { shortCode, userId },
});
if (!existing) {
throw new AppError('URL not found or does not belong to you', 404);
}
await prisma.url.delete({
where: { id: existing.id },
});
}
// ─── URL Stats ─────────────────────────────────────────
async getUrlStats(userId: string): Promise<{
totalUrls: number;
totalClicks: number;
urlsThisMonth: number;
}> {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [totalUrls, clickAgg, urlsThisMonth] = await Promise.all([
prisma.url.count({ where: { userId } }),
prisma.url.aggregate({
where: { userId },
_sum: { clickCount: true },
}),
prisma.url.count({
where: {
userId,
createdAt: { gte: firstOfMonth },
},
}),
]);
return {
totalUrls,
totalClicks: clickAgg._sum.clickCount || 0,
urlsThisMonth,
};
}
// ─── Private Helpers ───────────────────────────────────
private toUserUrl(url: any): UserUrl {
return {
id: url.id,
shortCode: url.shortCode,
shortUrl: `${env.baseUrl}/${url.shortCode}`,
originalUrl: url.originalUrl,
clickCount: url.clickCount,
customAlias: url.customAlias,
expiresAt: url.expiresAt,
createdAt: url.createdAt,
};
}
}Ownership Checks
Every operation checks { shortCode, userId } — users can only see and modify their own URLs. This is a fundamental security principle called row-level access control. Without it, any authenticated user could delete anyone else's URLs.
User URL Routes
// src/routes/userUrlRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { UserUrlService } from '../services/userUrlService';
import { verifyToken } from '../middleware/auth';
const router = Router();
const userUrlService = new UserUrlService();
// All routes require authentication
router.use(verifyToken);
// GET /api/my/urls — list my URLs with pagination
router.get(
'/urls',
async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const result = await userUrlService.listUrls(
req.user!.userId,
page,
pageSize,
search
);
res.json(result);
} catch (error) {
next(error);
}
}
);
// GET /api/my/urls/:code — get one of my URLs
router.get(
'/urls/:code',
async (req: Request, res: Response, next: NextFunction) => {
try {
const url = await userUrlService.getUrl(
req.params.code,
req.user!.userId
);
res.json(url);
} catch (error) {
next(error);
}
}
);
// PATCH /api/my/urls/:code — update one of my URLs
router.patch(
'/urls/:code',
async (req: Request, res: Response, next: NextFunction) => {
try {
const url = await userUrlService.updateUrl(
req.params.code,
req.user!.userId,
req.body
);
res.json(url);
} catch (error) {
next(error);
}
}
);
// DELETE /api/my/urls/:code — delete one of my URLs
router.delete(
'/urls/:code',
async (req: Request, res: Response, next: NextFunction) => {
try {
await userUrlService.deleteUrl(req.params.code, req.user!.userId);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
// GET /api/my/stats — get my URL stats
router.get(
'/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await userUrlService.getUrlStats(req.user!.userId);
res.json(stats);
} catch (error) {
next(error);
}
}
);
export default router;Update URL Shortening for Ownership
Now we need to update the existing URL shortening endpoint to attach the user ID when an authenticated user creates a link. Update UrlService:
// In src/services/urlService.ts — update the shortenUrl method
async shortenUrl(
originalUrl: string,
customAlias?: string,
userId?: string // NEW: optional user ID
): Promise<CreateUrlResponse> {
let shortCode: string;
if (customAlias) {
if (!isValidCustomAlias(customAlias)) {
throw new AppError(
'Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.',
400
);
}
const existingCode = await prisma.url.findUnique({
where: { shortCode: customAlias },
});
if (existingCode) {
throw new AppError('Custom alias is already taken', 409);
}
shortCode = customAlias;
} else {
shortCode = await this.generateUniqueCode();
}
const record = await prisma.url.create({
data: {
shortCode,
originalUrl,
userId: userId || null, // Attach user if authenticated
customAlias: !!customAlias,
},
});
return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
}And update the route to pass the user ID:
// In src/routes/urlRoutes.ts — update the POST /shorten handler
import { optionalAuth } from '../middleware/auth';
router.post(
'/shorten',
optionalAuth, // Use optional auth
validateShortenRequest,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { url, customAlias } = req.body;
const result = await urlService.shortenUrl(
url,
customAlias,
req.user?.userId // Pass user ID if authenticated
);
res.status(201).json(result);
} catch (error) {
next(error);
}
}
);This is backward compatible — anonymous users can still shorten URLs. But authenticated users get their URLs linked to their account.
Wire Routes into Express App
Update app.ts to mount the new auth and user URL routes:
// src/app.ts
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import authRoutes from './routes/authRoutes';
import userUrlRoutes from './routes/userUrlRoutes';
import { errorHandler } from './middleware/errorHandler';
const app = express();
app.use(express.json({ limit: '10kb' }));
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/my', userUrlRoutes);
app.use('/api', urlRoutes);
// Redirect catch-all (must be last)
app.use(redirectRoutes);
// Error handler
app.use(errorHandler);
export default app;Route mounting order:
/api/auth/*— authentication endpoints/api/my/*— user-scoped URL management (requires auth)/api/*— public URL shortening endpoints/:code— redirect catch-all
Security Best Practices
Password Storage
Never store plaintext passwords. Always hash with bcrypt and sufficient salt rounds:
// WRONG — storing plaintext
await prisma.user.create({
data: { email, password: plaintext },
});
// CORRECT — hash first
const hashed = await bcrypt.hash(plaintext, 12);
await prisma.user.create({
data: { email, password: hashed },
});Token Storage (Client Side)
| Storage | Access Token | Refresh Token |
|---|---|---|
| localStorage | Vulnerable to XSS | Never store here |
| sessionStorage | Lost on tab close | Never store here |
| Memory (variable) | Best for access tokens | Not persistent |
| HttpOnly cookie | Good alternative | Best for refresh tokens |
For our API-based approach, clients store the access token in memory and the refresh token in an HttpOnly cookie or secure storage.
Rate Limiting Auth Endpoints
Auth endpoints are prime targets for brute-force attacks. Add stricter rate limiting:
// src/middleware/authRateLimit.ts
import { Request, Response, NextFunction } from 'express';
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
export function loginRateLimit(
req: Request,
res: Response,
next: NextFunction
): void {
const key = req.ip || 'unknown';
const now = Date.now();
const record = loginAttempts.get(key);
if (record) {
if (now > record.resetAt) {
// Window expired, reset
loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
next();
return;
}
if (record.count >= MAX_ATTEMPTS) {
const retryAfter = Math.ceil((record.resetAt - now) / 1000);
res.status(429).json({
error: 'Too many login attempts',
retryAfter,
});
return;
}
record.count += 1;
} else {
loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
}
next();
}Apply it to the login route:
import { loginRateLimit } from '../middleware/authRateLimit';
router.post('/login', loginRateLimit, validateLogin, async (req, res, next) => {
// ...
});5 login attempts per 15 minutes per IP. After that, the user must wait. In production, use Redis for this instead of an in-memory Map (see Phase 5).
Token Cleanup
Expired refresh tokens pile up. Add a cleanup function:
// In authService.ts
async cleanupExpiredTokens(): Promise<number> {
const result = await prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
return result.count;
}Run this on a schedule (cron job) or as part of the refresh flow.
Updated Folder Structure
url-shortener/
├── src/
│ ├── config/
│ │ └── env.ts # + JWT config
│ ├── middleware/
│ │ ├── auth.ts # NEW: JWT + API key verification
│ │ ├── authRateLimit.ts # NEW: login rate limiting
│ │ ├── errorHandler.ts
│ │ ├── validateAuth.ts # NEW: auth request validation
│ │ └── validateRequest.ts
│ ├── routes/
│ │ ├── authRoutes.ts # NEW: register, login, refresh
│ │ ├── redirectRoutes.ts
│ │ ├── urlRoutes.ts # UPDATED: optional auth
│ │ └── userUrlRoutes.ts # NEW: user-scoped URL CRUD
│ ├── services/
│ │ ├── authService.ts # NEW: auth business logic
│ │ ├── urlService.ts # UPDATED: userId parameter
│ │ └── userUrlService.ts # NEW: user URL management
│ ├── types/
│ │ ├── auth.ts # NEW: auth types
│ │ └── url.ts
│ ├── app.ts # UPDATED: new route mounts
│ └── index.ts
├── prisma/
│ └── schema.prisma # UPDATED: User + RefreshToken models
├── .env
└── package.jsonTesting the Auth Flow
Start the dev server and test with curl:
Test 1: Register a User
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "SecurePass123",
"name": "Alice"
}'{
"message": "Registration successful",
"user": {
"id": "a1b2c3d4-...",
"email": "alice@example.com",
"name": "Alice",
"createdAt": "2026-03-21T10:00:00.000Z"
}
}Test 2: Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "SecurePass123"
}'{
"message": "Login successful",
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}Save the access token for subsequent requests:
export TOKEN="eyJhbGciOiJIUzI1NiIs..."Test 3: Shorten a URL (Authenticated)
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"url": "https://github.com/expressjs/express"}'{
"shortCode": "aBc1X9z",
"shortUrl": "http://localhost:3000/aBc1X9z",
"originalUrl": "https://github.com/expressjs/express",
"createdAt": "2026-03-21T10:01:00.000Z"
}This URL is now linked to Alice's account.
Test 4: List My URLs
curl http://localhost:3000/api/my/urls \
-H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "...",
"shortCode": "aBc1X9z",
"shortUrl": "http://localhost:3000/aBc1X9z",
"originalUrl": "https://github.com/expressjs/express",
"clickCount": 0,
"customAlias": false,
"expiresAt": null,
"createdAt": "2026-03-21T10:01:00.000Z"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}Test 5: Update a URL
curl -X PATCH http://localhost:3000/api/my/urls/aBc1X9z \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"originalUrl": "https://github.com/expressjs/express/releases"}'{
"id": "...",
"shortCode": "aBc1X9z",
"shortUrl": "http://localhost:3000/aBc1X9z",
"originalUrl": "https://github.com/expressjs/express/releases",
"clickCount": 0,
"customAlias": false,
"expiresAt": null,
"createdAt": "2026-03-21T10:01:00.000Z"
}Test 6: Delete a URL
curl -X DELETE http://localhost:3000/api/my/urls/aBc1X9z \
-H "Authorization: Bearer $TOKEN" \
-v< HTTP/1.1 204 No ContentTest 7: Generate API Key
curl -X POST http://localhost:3000/api/auth/api-key \
-H "Authorization: Bearer $TOKEN"{
"message": "API key generated. Store it securely — it cannot be retrieved later.",
"apiKey": "usk_a1b2c3d4e5f6..."
}Test 8: Use API Key
export API_KEY="usk_a1b2c3d4e5f6..."
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-H "x-api-key: $API_KEY" \
-d '{"url": "https://nodejs.org"}'{
"shortCode": "xY7mN2q",
"shortUrl": "http://localhost:3000/xY7mN2q",
"originalUrl": "https://nodejs.org",
"createdAt": "2026-03-21T10:05:00.000Z"
}Test 9: Refresh Token
curl -X POST http://localhost:3000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken": "eyJhbGciOiJIUzI1NiIs..."}'{
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
}Test 10: Error Cases
Duplicate email:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "AnotherPass123"}'{ "error": "Email is already registered" }Weak password:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "password": "weak"}'{ "error": "Password must be at least 8 characters" }Expired token:
{
"error": "Access token has expired",
"code": "TOKEN_EXPIRED"
}The code: "TOKEN_EXPIRED" lets the client know it should try the refresh flow instead of asking the user to log in again.
API Summary
Here's the complete API after this post:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /api/auth/register | No | Create account |
POST | /api/auth/login | No | Get tokens |
POST | /api/auth/refresh | No | Refresh access token |
POST | /api/auth/logout | No | Revoke refresh token |
POST | /api/auth/logout-all | Yes | Revoke all refresh tokens |
GET | /api/auth/profile | Yes | Get user profile |
POST | /api/auth/api-key | Yes | Generate API key |
DELETE | /api/auth/api-key | Yes | Revoke API key |
POST | /api/shorten | Optional | Shorten a URL |
GET | /api/urls/:code | No | Get URL stats |
GET | /api/my/urls | Yes | List my URLs |
GET | /api/my/urls/:code | Yes | Get one of my URLs |
PATCH | /api/my/urls/:code | Yes | Update my URL |
DELETE | /api/my/urls/:code | Yes | Delete my URL |
GET | /api/my/stats | Yes | My URL statistics |
GET | /:code | No | Redirect |
GET | /health | No | Health check |
Common Mistakes to Avoid
1. Storing JWT Secrets in Code
// WRONG — secret in source code
const secret = 'my-super-secret-key';
// CORRECT — load from environment
const secret = process.env.JWT_ACCESS_SECRET;2. Not Checking Token Ownership on Update/Delete
// WRONG — anyone can delete any URL
const url = await prisma.url.findUnique({ where: { shortCode } });
await prisma.url.delete({ where: { id: url.id } });
// CORRECT — verify ownership
const url = await prisma.url.findFirst({
where: { shortCode, userId },
});
if (!url) throw new AppError('Not found or not yours', 404);
await prisma.url.delete({ where: { id: url.id } });3. Leaking Password Hashes in API Responses
// WRONG — sends everything, including password hash
res.json(user);
// CORRECT — map to safe response
res.json({
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt,
});4. Using the Same Secret for Access and Refresh Tokens
# WRONG — same secret for both
JWT_SECRET=one-secret-for-everything
# CORRECT — separate secrets
JWT_ACCESS_SECRET=access-secret-here
JWT_REFRESH_SECRET=different-refresh-secretIf both tokens share a secret, a leaked access token could be used as a refresh token (or vice versa), defeating the whole purpose of the two-token strategy.
What's Next?
We now have user authentication, URL ownership, and API keys. In Phase 7, we'll build a React frontend:
- Vite + React + TypeScript project setup
- Login and registration pages
- URL shortening form with results display
- Dashboard with paginated URL list
- Click stats visualization
- API key management UI
Our API is ready — now we need a face for it.
Series: Build a URL Shortener
Previous: Phase 5: Caching with Redis
Next: Phase 7: Frontend with React
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.