"Security isn't a feature you add at the end. It's the foundation you build everything else on top of."
Introduction: How Does a Website Know Who You Are?
Think about the last time you logged into a website. You typed your email and password, clicked a button, and suddenly the app knew who you were — your name appeared, your data loaded, your settings were right.
That moment involves several invisible systems working together. How did the server verify your password without storing it somewhere a hacker could read? How did it decide what you're allowed to see? How does "Login with Google" work without giving Google your password?
These questions touch on the core of application security. This guide answers all of them — from the fundamentals of password storage through modern authentication architectures used by the world's largest platforms.
1. Why Application Security Matters
Common Security Risks in Web Applications
Web applications face a consistent set of threats:
Credential theft — Attackers obtain usernames and passwords, either by breaching a database or by tricking users through phishing.
Privilege escalation — A regular user gains access to admin functionality by manipulating requests or exploiting missing authorization checks.
Session hijacking — An attacker steals an active session token and impersonates a legitimate user without ever knowing their password.
Injection attacks — Malicious input is used to manipulate database queries, expose data, or execute unauthorized commands.
Credential stuffing — Attackers use leaked username/password combinations from one breach to attempt logins on hundreds of other services, banking on users reusing passwords.
Authentication vs Authorization: Two Different Problems
These words are often used interchangeably, but they represent distinct concerns:
AUTHENTICATION AUTHORIZATION
"Who are you?" "What are you allowed to do?"
Verifying identity Enforcing permissions
Login with password, biometric, token Role-based access, feature flags
Happens first Happens after authentication
Example: Proving you're Alice Example: Alice can edit posts
at the company entrance but not delete them
A system can authenticate perfectly and still authorize incorrectly. A user with a valid login might be able to access another user's data if authorization isn't enforced on every request.
The Cost of Insecure Systems
Security failures are expensive — measured in money, reputation, and legal liability:
LinkedIn (2012) — 117 million password hashes leaked. Passwords were hashed with unsalted MD5, a weak algorithm, allowing attackers to crack millions of them within days. LinkedIn paid $1.25 million in a class action settlement.
Adobe (2013) — 153 million user records exposed, including passwords encrypted (not hashed) with a weak, reversible algorithm. Because the same "encrypted" value was used for identical passwords, attackers could cross-reference records to crack them.
RockYou2024 (2024) — Nearly 10 billion unique plaintext passwords compiled from decades of breaches and released publicly, fuelling credential stuffing attacks across the web.
The pattern across every major breach: passwords were either stored in plain text, hashed with outdated algorithms, or hashed without salting. All three are preventable.
2. Password Hashing and Storage
Why Passwords Should Never Be Stored in Plain Text
Storing a plain-text password means a database breach immediately hands attackers every user's password, verbatim. They don't need to crack anything — they just read the column.
The same applies to reversible encryption. If your server can decrypt a password to verify it, an attacker with access to the encryption key can decrypt every password in the database.
The correct approach is one-way hashing — a transformation that can't be reversed.
What Hashing Is
A hash function converts input of any length into a fixed-length output (the hash or digest). The same input always produces the same output, but the process is irreversible — you cannot recover the original input from the hash.
"mypassword" ──► bcrypt ──► $2b$12$...rFqLbHFSEKkQo7f7qAW6u
"mypassword" ──► bcrypt ──► $2b$12$...rFqLbHFSEKkQo7f7qAW6u ← same
"myPassword" ──► bcrypt ──► $2b$12$...t8ZUkPqNzDqFvLxm1Y3Xi ← different
The comparison direction matters. To verify a password at login:
User types password ──► hash it ──► compare to stored hash
↑
The stored hash never needs to be "undecrypted"
The Problem with Simple Hashing: Rainbow Tables
If you hash "password" with SHA-256 on any computer in the world, you get the same result. Attackers exploit this by precomputing hashes for millions of common passwords — a rainbow table — and comparing them against leaked hashes.
Two more problems arise with naive hashing:
Identical passwords produce identical hashes. If Alice and Bob both use "hunter2", their hashes are identical. Crack one, crack both.
Fast hashing algorithms are a liability. SHA-256 and MD5 were designed to be fast. Modern hardware can compute billions of SHA-256 hashes per second — making brute force attacks tractable.
How bcrypt Solves Both Problems
bcrypt is a password hashing function designed specifically for storing passwords. It addresses every weakness of naive hashing:
1. Salting — defeats rainbow tables and identical-password leaks
Before hashing, bcrypt generates a random value called a salt and incorporates it into the hash. The salt is stored alongside the hash (it doesn't need to be secret — its job is uniqueness, not secrecy).
PASSWORD HASHING WITH SALT
User A: "hunter2" + salt_A ──► hash_A ← unique
User B: "hunter2" + salt_B ──► hash_B ← different despite same password
Each user's hash is unique, even with an identical password.
Rainbow tables are useless — attackers would need one per salt.
2. Work factor — defeats brute force
bcrypt includes a configurable cost factor (also called work factor or rounds). Higher rounds mean more computation per hash — making each attempt slower. This is intentional.
COST FACTOR IMPACT (approximate on modern hardware)
cost=10 → ~100ms per hash ← 10 attempts/sec for attacker
cost=12 → ~400ms per hash ← 2.5 attempts/sec for attacker
cost=14 → ~1500ms per hash ← <1 attempt/sec for attacker
For users: a 100–400ms login is imperceptible
For attackers: 100ms × billions of attempts = years
The cost factor can be increased over time as hardware improves, without invalidating existing hashes — just re-hash passwords when users log in next.
3. The full bcrypt hash contains everything needed for verification
$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
↑ ↑ ↑ ↑
| | salt (22 chars) hash (31 chars)
| cost factor (12 rounds)
algorithm version (2b)
No separate lookup table needed — everything required to verify the password is in the hash string itself.
Password Verification Process
REGISTRATION LOGIN
User: "mysecretpassword" User: "mysecretpassword"
│ │
▼ ▼
bcrypt.hash(password, 12) bcrypt.compare(
│ inputPassword,
▼ storedHash
$2b$12$...Kq7mN8pLrXt )
│ │
▼ ▼
Store in database true / false
(never store plain text)
const bcrypt = require("bcrypt");
// Registration — hash before storing
async function registerUser(email, plainPassword) {
const SALT_ROUNDS = 12;
const hashedPassword = await bcrypt.hash(plainPassword, SALT_ROUNDS);
await db.users.create({ email, password: hashedPassword });
// Store hashedPassword — NEVER plainPassword
}
// Login — compare without decrypting
async function loginUser(email, plainPassword) {
const user = await db.users.findByEmail(email);
if (!user) return null; // User not found
const isMatch = await bcrypt.compare(plainPassword, user.password);
if (!isMatch) return null; // Wrong password
return user; // Authenticated
}
Best Practices for Password Storage
DO DO NOT
─────────────────────────────────────────────────────────────────
Use bcrypt, scrypt, or Argon2 Use MD5, SHA-1, SHA-256 alone
Use cost factor ≥ 12 for bcrypt Store plain text passwords
Let bcrypt generate the salt Use reversible encryption
Return generic error messages Indicate whether email or
("Invalid credentials") password was wrong specifically
Re-hash on login if cost is outdated Log passwords anywhere
Enforce a minimum password length Truncate long passwords
3. Authentication vs Authorization
What Authentication Means
Authentication is the process of verifying that a user is who they claim to be. It answers the question: "Are you really Alice?"
Authentication happens at the boundary of your system — when a user presents credentials and the system decides whether those credentials are valid.
Authentication methods include:
- Something you know: password, PIN, security question
- Something you have: OTP from an authenticator app, SMS code, hardware key
- Something you are: fingerprint, face ID, voice
Multi-factor authentication (MFA) requires two or more of these — so a stolen password alone isn't enough.
What Authorization Means
Authorization determines what an authenticated user is allowed to do. It answers the question: "Alice is authenticated — what can she access?"
Authorization is checked continuously — on every request, every route, every API call. It's not a one-time gate at login; it's a question asked every time a resource is touched.
Authorization is not:
"Did Alice log in?" → That's authentication.
Authorization is:
"Is Alice allowed to DELETE /posts/42?" → That's authorization.
"Can Alice see the billing dashboard?" → That's authorization.
"Is Alice's subscription tier high enough?" → That's authorization.
Why Applications Need Both
AUTHENTICATION + AUTHORIZATION TOGETHER
Without authentication:
Anyone can claim to be Alice → impersonation
Without authorization:
Alice can access Bob's data, admin panels, billing records
Without both:
Anyone can do anything → the application is open
With both:
✓ Identity is verified at login
✓ Permissions are enforced on every request
✓ Users see only what they're allowed to see
Everyday Examples
| Application | Authentication | Authorization |
|---|---|---|
| Gmail | Password + 2FA | Read your own emails; not others' |
| Notion | Google SSO | Viewers can read; editors can write |
| Stripe Dashboard | Password + hardware key | Developers see logs; Owners see financials |
| GitHub | Username + SSH key | Collaborators can push; Maintainers can merge |
| Hospital system | Badge + PIN | Nurses see patient notes; Doctors see prescriptions |
4. Role-Based Access Control (RBAC)
What RBAC Is
Role-Based Access Control is an authorization model where permissions are assigned to roles, and roles are assigned to users. Instead of granting individual permissions directly to each user, you define roles that represent job functions and assign users to those roles.
WITHOUT RBAC (permission per user — doesn't scale)
Alice: [read_posts, write_posts, delete_posts, manage_users, view_analytics]
Bob: [read_posts, write_posts]
Carol: [read_posts, write_posts, delete_posts, view_analytics]
WITH RBAC (permission per role — scales cleanly)
ROLE admin: [read_posts, write_posts, delete_posts, manage_users, view_analytics]
ROLE editor: [read_posts, write_posts, delete_posts, view_analytics]
ROLE writer: [read_posts, write_posts]
ROLE viewer: [read_posts]
Alice → admin
Bob → writer
Carol → editor
When Alice's role changes from admin to editor, you update one assignment — not a list of individual permissions. When a new permission is created, you add it to the role — all users in that role immediately have it.
Users, Roles, and Permissions
The RBAC model has three building blocks:
User — A person with an account in the system.
Role — A named collection of permissions, representing a job function or access level (admin, editor, billing_manager, support_agent).
Permission — A specific action on a specific resource (read:posts, delete:users, view:billing, publish:articles).
The relationship chain is: User → has Role → has Permissions
RBAC ENTITY RELATIONSHIP
┌─────────┐ assigned ┌──────────┐ contains ┌────────────────┐
│ User │ ────────────────► │ Role │ ─────────────► │ Permission │
│ │ (many-to-many)│ │ (many-to-many)│ │
│ Alice │ │ admin │ │ delete:posts │
│ Bob │ │ editor │ │ manage:users │
│ Carol │ │ viewer │ │ view:analytics │
└─────────┘ └──────────┘ └────────────────┘
Role Hierarchy Example: Blog Platform
ROLE HIERARCHY — BLOG PLATFORM
┌──────────────────────────────────────┐
│ ADMIN │
│ manage users · manage roles │
│ delete any content · view analytics │
│ all editor permissions │
└──────────────────┬───────────────────┘
│ inherits from
┌──────────────────▼───────────────────┐
│ EDITOR │
│ publish articles · delete own posts │
│ moderate comments · view analytics │
│ all writer permissions │
└──────────────────┬───────────────────┘
│ inherits from
┌──────────────────▼───────────────────┐
│ WRITER │
│ create posts · edit own posts │
│ upload images │
│ all viewer permissions │
└──────────────────┬───────────────────┘
│ inherits from
┌──────────────────▼───────────────────┐
│ VIEWER │
│ read published articles │
│ post comments │
└──────────────────────────────────────┘
Implementing RBAC in Express
// Middleware to check role
function requireRole(...allowedRoles) {
return (req, res, next) => {
const userRole = req.user?.role; // Set during authentication
if (!userRole || !allowedRoles.includes(userRole)) {
return res.status(403).json({
error: "Forbidden — insufficient permissions"
});
}
next();
};
}
// Permission-level check (more granular than role check)
function requirePermission(permission) {
return (req, res, next) => {
const userPermissions = req.user?.permissions || [];
if (!userPermissions.includes(permission)) {
return res.status(403).json({
error: "Forbidden — missing required permission"
});
}
next();
};
}
// Route protection examples
app.get("/admin/users",
requireRole("admin"), // only admins
listAllUsers
);
app.delete("/posts/:id",
requireRole("admin", "editor"), // admins and editors
deletePost
);
app.post("/posts",
requireRole("admin", "editor", "writer"),
createPost
);
app.get("/posts", // no role check — public
listPosts
);
Scaling Permissions in Large Applications
As applications grow, flat role lists become insufficient. Common patterns for scaling:
Attribute-Based Access Control (ABAC) — Permissions based on attributes of the user, the resource, and the environment. "Alice can edit this document because she's in the Engineering team and this document's department is Engineering."
Resource ownership — Users can always perform certain actions on resources they created, regardless of their global role.
Scoped permissions — Permissions limited to a specific organization, workspace, or tenant. Alice might be admin in workspace A but only viewer in workspace B.
5. OAuth 2.0 Explained
The Problem OAuth Solves
Before OAuth, the only way for one application to access your data in another was to give it your username and password. Want a printing service to access your Google Photos? Type your Google password into the printing service's form.
The problems with this approach are obvious:
PROBLEMS WITH SHARING PASSWORDS
1. You expose your entire account to the third party
→ They could read your emails, see all your files, access everything
2. You have no way to limit what they can access
→ There's no "give access to photos only"
3. You can't revoke access without changing your password
→ Which logs out every other service using that password
4. You can't tell what the third party is doing with your credentials
→ No audit trail of their actions
OAuth 2.0 is an authorization framework that lets users grant third-party applications limited, scoped access to their account — without sharing their password.
The Four Actors in OAuth
OAUTH 2.0 ACTORS
┌──────────────────┐ owns data ┌──────────────────┐
│ Resource Owner │ ─────────────── │ Resource Server │
│ (the user) │ │ (Google Photos, │
│ │ │ GitHub, etc.) │
└────────┬─────────┘ └──────────────────┘
│
│ grants permission to
│
┌────────▼─────────┐ gets token from ┌──────────────────┐
│ Client App │ ──────────────── │ Authorization │
│ (the third-party │ │ Server │
│ application) │ │ (issues tokens) │
└──────────────────┘ └──────────────────┘
Resource Owner — The user who owns the data and decides what to share.
Client Application — The third-party app requesting access (a photo editor, a code analysis tool, a calendar integration).
Authorization Server — The server that authenticates the user and issues access tokens (Google's auth server, GitHub's auth server, your own identity provider).
Resource Server — The API server holding the actual data (Google Photos API, GitHub API). Often the same system as the authorization server.
The Authorization Code Flow
The most common OAuth flow for web applications:
OAUTH 2.0 — AUTHORIZATION CODE FLOW
User Client App Auth Server Resource Server
│ │ │ │
│ 1. Click │ │ │
│ "Login with │ │ │
│ Google" │ │ │
│ ─────────────────► │ │ │
│ │ 2. Redirect to │ │
│ │ auth server with │ │
│ │ scope + client_id │ │
│ │ ─────────────────► │ │
│ │ │ │
│ 3. Google shows consent screen │ │
│ ◄────────────────────────────────────── │ │
│ │ │ │
│ 4. User approves │ │ │
│ ─────────────────────────────────────── ►│ │
│ │ │ │
│ │ 5. Auth server sends authorization code│
│ │ ◄───────────────── │ │
│ │ │ │
│ │ 6. Client exchanges code for │
│ │ access token (server-to-server) │
│ │ ─────────────────► │ │
│ │ │ │
│ │ 7. Access token │ │
│ │ ◄───────────────── │ │
│ │ │ │
│ │ 8. Use access token to call API │
│ │ ──────────────────────────────────────► │
│ │ │ │
│ │ 9. Protected resource data │
│ │ ◄────────────────────────────────────── │
Access Tokens and Scopes
An access token is a credential that represents the user's grant of access to the client application. It's typically a signed string (often a JWT) that the resource server validates on every API call.
Scopes limit what the token can access. The client declares which scopes it needs, the user approves them, and the token is bound to those scopes:
Common OAuth scopes:
GitHub: repo → read/write repos
read:user → read profile info
gist → create gists
notifications → read notifications
Google: email → read email address
profile → read basic profile info
https://www.googleapis.com/auth/calendar → full calendar access
https://www.googleapis.com/auth/drive.readonly → read-only Drive
The principle: request only the scopes you need. A tool that reads your GitHub issues doesn't need repo (which would let it push code).
Real-World Example: "Login with GitHub"
What the user sees:
"Would you like to authorize CodeAnalyzer to:
✓ Read your public profile information
✓ Access your public repositories
Authorize App | Cancel"
What's happening behind the scenes:
→ Client requests scopes: ["read:user", "public_repo"]
→ User approves on GitHub's own server
→ Client receives access token
→ Client can now call GitHub API with that token
→ Client CANNOT read your emails, private repos, or billing info
→ You can revoke this token any time in GitHub Settings → Applications
6. OpenID Connect (OIDC)
What OAuth 2.0 Is Missing
OAuth 2.0 solves authorization — it lets applications access resources on your behalf. But it doesn't answer an important question: who are you?
OAuth provides an access token that says "the bearer of this token is allowed to read photos." But it doesn't say whose photos, or anything about the user's identity. Applications had to hack around this by calling a separate /me or /userinfo endpoint — and every provider implemented it differently.
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that standardizes the answer to "who are you?" It adds authentication to OAuth's authorization.
How OIDC Extends OAuth
OIDC adds one critical thing to the OAuth flow: an ID token.
OAUTH 2.0 (authorization only)
→ Access token: "I can read your photos"
OPENID CONNECT (authorization + authentication)
→ Access token: "I can read your photos" ← same as OAuth
→ ID token: "You are Alice, born 1990, email alice@example.com" ← new
The ID token is a JWT (JSON Web Token) — a signed, structured object that contains claims about the user's identity:
{
"iss": "https://accounts.google.com",
"sub": "10769150350006150715113082367",
"aud": "your-client-id.apps.googleusercontent.com",
"exp": 1735689600,
"iat": 1735686000,
"name": "Alice Smith",
"email": "alice@example.com",
"picture": "https://lh3.googleusercontent.com/...",
"email_verified": true
}
These fields are called claims. The application can trust them because the ID token is cryptographically signed by the identity provider.
The OIDC Flow
OPENID CONNECT FLOW
1. Client redirects user to identity provider with:
scope: openid profile email ← "openid" scope triggers OIDC
2. User authenticates with their identity provider
(enters password, uses biometric, passes MFA)
3. Identity provider returns:
→ Authorization code (same as OAuth)
4. Client exchanges code for:
→ Access token (for calling APIs)
→ ID token (contains user identity)
→ Refresh token (optional, for long sessions)
5. Client validates the ID token signature using
the identity provider's public key
6. Client extracts user identity from ID token claims:
sub = "unique user ID" (never changes for this user)
email = "alice@example.com"
name = "Alice Smith"
7. Client creates a local session for Alice
Authentication vs Authorization in OIDC
IN AN OIDC SYSTEM:
ID token = authentication result
→ "This is Alice" (signed by Google/Okta/your IdP)
Access token = authorization grant
→ "Alice allowed this app to read her calendar"
Refresh token = session extension mechanism
→ "Silently get a new access token when this one expires"
Why OIDC Matters for Modern Applications
Before OIDC, "Login with Google" wasn't a standard — every provider (Google, Facebook, Twitter) had different endpoints, different response formats, different claim names. Developers had to write separate integration code for each.
OIDC standardizes everything. An application built to OIDC spec works with any compliant identity provider — Google, Microsoft, Okta, Auth0, GitHub, your own corporate identity server — by changing configuration, not code.
7. Modern Authentication Architecture
From Traditional to Modern
TRADITIONAL LOGIN SYSTEM (2000s)
Browser ──────────────────► Your Server
├── Check username/password
├── Create server-side session
├── Set session cookie
└── Store session in memory/DB
Every request:
Browser sends cookie ──► Server looks up session ──► Get user data
Problems:
→ Sessions stored on server (memory/database) — hard to scale
→ Works per-domain only — can't share login across subdomains or apps
→ Each application manages its own user database and auth code
MODERN TOKEN-BASED SYSTEM (today)
Browser ──────────────────► Auth Server (dedicated)
├── Verify credentials
├── Issue JWT access token (signed)
└── Issue refresh token
Every API request:
Browser sends JWT ──► API validates signature (stateless) ──► Serve data
Advantages:
→ Stateless — no server-side session storage
→ Scalable — any server can validate the signature
→ Cross-domain — token works across subdomains and APIs
Social Login
Social login (Login with Google/GitHub/Apple) uses OIDC:
SOCIAL LOGIN ARCHITECTURE
Your App Google OIDC Your Database
│ │ │
│ 1. Redirect to │ │
│ Google with │ │
│ scope=openid email │ │
│ ────────────────────► │ │
│ │ 2. User logs in to │
│ │ Google (not your app) │
│ 3. ID token │ │
│ ◄──────────────────── │ │
│ │ │
│ 4. Extract sub (Google's unique user ID) │
│ ─────────────────────────────────────────────────►│
│ │ │
│ 5. Find or create user record by sub │
│ ◄─────────────────────────────────────────────────│
│ │ │
│ 6. Issue your own session/JWT for the user │
You never see the user's Google password. You receive a verified, signed assertion from Google that this person authenticated successfully and here is their identity.
Single Sign-On (SSO)
SSO lets users authenticate once and access multiple applications without logging in again.
SINGLE SIGN-ON ARCHITECTURE
┌─────────────────────┐
│ Identity Provider │
│ (IdP) │
│ (Okta, Azure AD, │
│ Google Workspace) │
└──────────┬──────────┘
│ issues tokens
┌──────────────┼──────────────┐
│ │ │
┌──────▼──────┐ ┌────▼──────┐ ┌────▼──────┐
│ CRM App │ │ HR Portal │ │ Dev Tools│
│ (Salesforce)│ │ (Workday)│ │ (GitHub) │
└─────────────┘ └───────────┘ └───────────┘
User logs in once to the IdP → all three apps recognize the session
Log out from IdP → logged out from all three simultaneously
SSO is standard in enterprise environments. Employees authenticate with corporate credentials once, and all internal tools (email, project management, HR, developer tools) share that authentication.
Authentication in Modern SaaS Products
MULTI-TENANT SAAS AUTHENTICATION
Personal accounts: Email/password + MFA, social login (OIDC)
Team accounts: SSO with company IdP (Okta, Azure AD)
API access: API keys or OAuth tokens with scopes
Machine-to-machine: OAuth client credentials flow (no user involved)
Administrative: Separate admin portal, hardware MFA required
8. Security Best Practices
Strong Password Policies
MINIMUM PASSWORD REQUIREMENTS (modern guidance)
Length: 12+ characters minimum (NIST recommends 14-64)
Characters: Allow all printable characters + spaces
Complexity: Don't mandate special chars — encourages predictable patterns
(P@ssw0rd! is weaker than a long passphrase)
Blocklist: Reject passwords found in breach databases (HaveIBeenPwned)
Rotation: Don't force periodic rotation — causes weak patterns
MFA: Treat MFA as a stronger control than complex passwords
Multi-Factor Authentication (MFA)
MFA requires a second factor beyond the password. Even if a password is leaked, an attacker without the second factor can't log in.
| Factor Type | Example | Strength |
|---|---|---|
| SMS code | 6-digit code via text | Low (SIM swapping) |
| TOTP app | Google Authenticator, Authy | Medium-high |
| Hardware key | YubiKey, FIDO2 | Very high |
| Push notification | Duo, Okta Verify | High |
| Passkey (biometric) | Face ID, Windows Hello | Very high |
For high-security routes (admin panels, payment operations, account changes), require re-authentication or step-up MFA even for already-authenticated sessions.
Token Expiration and Refresh Strategy
TOKEN LIFECYCLE
Access token: Short-lived (15 minutes – 1 hour)
Used for every API request
If stolen: attacker has access for a short window only
Refresh token: Long-lived (days to weeks)
Stored securely (HTTP-only cookie, secure storage)
Used only to get new access tokens
If stolen: attacker can get new access tokens
Rotation: Issue a new refresh token on every refresh
Invalidate the old one
If refresh token is reused → detect theft, revoke all sessions
Revocation: Maintain a token revocation list (blocklist) for
logout, password change, or suspected compromise
// Access token payload — keep it small
{
"sub": "user-123", // user identifier
"role": "editor", // for authorization
"iat": 1735686000, // issued at
"exp": 1735689600 // expires in 1 hour
}
// What NOT to include in tokens
// ❌ password hash
// ❌ sensitive personal data (full SSN, credit card)
// ❌ large permission lists (keep tokens small)
Principle of Least Privilege
Every user, role, service, and API key should have the minimum permissions required to perform its function — nothing more.
PRINCIPLE OF LEAST PRIVILEGE IN PRACTICE
API keys:
❌ One master API key with full access for all services
✅ Separate key per service, scoped to only what it needs
Service accounts:
❌ All microservices run as admin
✅ Each microservice has a role with only its required permissions
User roles:
❌ Default new users to admin "for convenience"
✅ Default new users to viewer; elevate permissions explicitly
OAuth scopes:
❌ Request all available scopes at login
✅ Request only scopes needed for current features
✅ Request sensitive scopes only when needed (incremental auth)
Protecting Sensitive Routes
// Layer multiple controls on sensitive endpoints
app.delete("/admin/users/:id",
authenticate, // 1. Must be logged in
requireMFA, // 2. Must have MFA verified recently
requireRole("admin"), // 3. Must be admin
requirePermission("delete:users"), // 4. Must have specific permission
logAuditEvent("user.deleted"), // 5. Log every admin action
deleteUser
);
// Protect against CSRF on state-changing requests
app.use(csrf({ cookie: true }));
// Rate-limit login endpoint to prevent brute force
app.use("/auth/login", rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10 // 10 attempts per window
}));
// Always use HTTPS — HTTP leaks tokens in transit
app.use((req, res, next) => {
if (!req.secure) return res.redirect(`https://${req.host}${req.url}`);
next();
});
Secure Storage of Credentials
WHERE TO STORE TOKENS (CLIENT SIDE)
localStorage sessionStorage HTTP-only Cookie
──────────────────────────────────────────────────────────────────
XSS risk HIGH HIGH NONE
CSRF risk NONE NONE YES (mitigate)
Persists Yes Tab only Configurable
Server control No No Yes (expire/revoke)
Recommended for Short-lived Temporary Refresh tokens
non-sensitive UI state Long sessions
──────────────────────────────────────────────────────────────────
Best practice:
Access tokens → Short-lived, memory only (JS variable, not storage)
Refresh tokens → HTTP-only, Secure, SameSite=Strict cookie
API keys → Environment variables (never in code, never in git)
Secrets → Secret management service (AWS Secrets Manager, Vault)
Security Layers in a Complete Application
DEFENSE IN DEPTH — SECURITY LAYERS
Request arrives
│
┌─────▼────────────────────────────────────┐
│ 1. TRANSPORT LAYER │
│ HTTPS/TLS — encrypt data in transit │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 2. NETWORK LAYER │
│ Rate limiting · IP allowlisting │
│ DDoS protection · WAF rules │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 3. AUTHENTICATION LAYER │
│ Verify identity · Validate token │
│ Check MFA · Detect anomalies │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 4. AUTHORIZATION LAYER │
│ Check role · Verify permissions │
│ Enforce resource ownership │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 5. INPUT VALIDATION LAYER │
│ Sanitize input · Validate types │
│ Prevent injection · Schema validate │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 6. DATA LAYER │
│ Parameterized queries · Encryption │
│ Hashed passwords · Minimal exposure │
└─────┬────────────────────────────────────┘
│
┌─────▼────────────────────────────────────┐
│ 7. AUDIT LAYER │
│ Log all sensitive actions │
│ Alert on anomalies · Retain logs │
└──────────────────────────────────────────┘
No single layer is impenetrable. Defense in depth means an attacker who bypasses one layer still faces all the others.
Quick Reference
| Concept | One-line definition |
|---|---|
| Authentication | Verifying who a user is |
| Authorization | Controlling what a user can do |
| Hashing | One-way transformation of a value |
| Salt | Random value added before hashing to prevent rainbow tables |
| bcrypt | Password hashing function with configurable work factor |
| RBAC | Permissions assigned to roles, roles assigned to users |
| OAuth 2.0 | Framework for delegated authorization without sharing passwords |
| OIDC | Identity layer on top of OAuth that standardizes user authentication |
| Access token | Short-lived credential authorizing API access |
| Refresh token | Long-lived credential used to obtain new access tokens |
| ID token | JWT containing verified claims about user identity (OIDC) |
| SSO | Authenticate once, access multiple applications |
| MFA | Require two or more authentication factors |
| Scope | A permission boundary on an OAuth token |
| JWT | Signed, structured token format used for access and ID tokens |
Key Takeaways
- Authentication and authorization are separate problems — both are required, and authorization must be checked on every request, not just at login
- Never store plain-text passwords — use bcrypt (cost ≥ 12), which handles salting automatically and is intentionally slow to resist brute force
- OAuth 2.0 enables delegated authorization — users grant third-party apps scoped access without sharing their password
- OpenID Connect adds authentication to OAuth — the ID token carries verified user identity claims in a standardized format
- RBAC decouples permissions from users — assign permissions to roles, assign roles to users; changing a role changes everyone in it
- Defense in depth — security must be enforced at transport, authentication, authorization, input validation, and data layers
- Principle of least privilege — every user, service, and token should have the minimum access required
- Short access tokens, secure refresh tokens — access tokens expire quickly; refresh tokens live longer but are stored in HTTP-only cookies
What's Next?
With a solid foundation in application security, these topics extend naturally:
- JWT deep dive — the structure of JSON Web Tokens, signature verification, and common attack vectors (algorithm confusion, key confusion)
- PKCE (Proof Key for Code Exchange) — hardening the OAuth flow for public clients like mobile apps and SPAs
- Passkeys and WebAuthn — the passwordless future: device-based cryptographic authentication that eliminates phishing
- Zero-trust architecture — never trust, always verify: extending least privilege across networks, services, and identities
- OWASP Top 10 — the ten most critical web application security risks, with mitigation strategies for each
Security is not a checklist you complete once — it's a continuous practice of understanding threats, applying defenses, and monitoring for what slips through.
"The best security is the kind users never notice — they just feel safe." 🔐
Top comments (0)