# Securing Web Apps: Password Hashing, RBAC, OAuth 2.0, and OpenID Connect Explained

# Securing Web Apps: Password Hashing, RBAC, OAuth 2.0, and OpenID Connect Explained

> **TL;DR:** Authentication proves *who you are*; authorization decides *what you can do*. This article builds the complete security stack — bcrypt password hashing, role-based access control, OAuth 2.0 delegation, and OpenID Connect identity — with real code, honest trade-offs, and production best practices.

## Introduction

Start with a simple question: *How does a website know who you are?*

You type an email and a password. The server looks you up. Something happens. You're in. That "something" is the entire subject of this article, and when it goes wrong the consequences are measured in millions of leaked credentials, destroyed reputations, and regulatory fines.

In 2019, Facebook stored hundreds of millions of passwords in plaintext in internal logs. In 2012, LinkedIn lost 117 million unsalted SHA-1 password hashes — a format crackable at billions of guesses per second on commodity hardware. These are not ancient history. They are the direct result of skipping the fundamentals this article covers.

**Who this is for:** Backend and full-stack developers who understand HTTP and have written at least one server-side application. Cryptography will be kept high-level — what matters is the *system design* and *why each layer exists*.

We'll build the mental model in layers:
1. Passwords — store them safely
2. Authentication — verify identity
3. Authorization — enforce permissions
4. OAuth 2.0 — delegate access without sharing passwords
5. OpenID Connect — prove identity on top of OAuth

---

## Background: The Two Problems Every App Must Solve

Before writing a single line of security code, internalize this distinction:

| Concept | Question answered | Example |
|---|---|---|
| **Authentication** | *Who are you?* | Username + password login, Google Sign-In |
| **Authorization** | *What are you allowed to do?* | Admins can delete posts; viewers can only read |

These are fundamentally different problems. An authenticated user is not automatically authorized to do everything. A misconfigured authorization layer means a regular user can call `/api/admin/delete-all-users` — and it will work.

```mermaid
flowchart LR
  User(["👤 User"])
  AuthN["Authentication\n(Who are you?)"]
  AuthZ["Authorization\n(What can you do?)"]
  Resource["Protected Resource"]

  User -->|"credentials"| AuthN
  AuthN -->|"identity confirmed"| AuthZ
  AuthZ -->|"permission granted"| Resource
  AuthZ -->|"403 Forbidden"| Denied(["❌ Access Denied"])
```

> **Note:** Every security vulnerability in this space traces back to either failing to verify identity correctly (authentication flaw) or failing to check permissions correctly (authorization flaw). Keep this distinction sharp.

---

## Password Hashing and Storage

### Why Plaintext and Simple Hashing Both Fail

Storing a password in plaintext is indefensible — a single database breach exposes every user's credential immediately. But storing a plain MD5 or SHA-256 hash is nearly as bad. These algorithms are designed to be *fast*, which is exactly what you don't want. An attacker with a leaked hash file can test billions of candidates per second using rainbow tables or GPU-accelerated brute force.

### How bcrypt Works

**bcrypt** is a password-hashing function designed by Niels Provos and David Mazières in 1999. It has three properties that make it suitable for passwords:

1. **It's slow by design.** A configurable *work factor* (also called *cost*) controls how many rounds of processing occur. Increasing the cost by 1 doubles the computation time.
2. **It incorporates a salt automatically.** A random 128-bit salt is generated for each hash, making precomputed rainbow tables useless.
3. **The salt is stored in the hash string itself.** The output is a single self-contained string — no separate salt column needed.

A bcrypt hash looks like this:

```
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdBdXIG/IQKW7kC
```

- `$2b$` — bcrypt version
- `12` — cost factor (2¹² = 4096 iterations)
- Next 22 characters — the base64-encoded salt
- Remaining characters — the hash

### Implementing bcrypt in Node.js

Install the library:

```bash
npm install bcrypt
```

**Hashing a password at registration:**

```js
// auth/passwordUtils.js
const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12; // cost factor — tune based on your server speed

/**
 * Hash a plaintext password before storing it.
 * @param {string} plaintextPassword
 * @returns {Promise<string>} The bcrypt hash string
 */
async function hashPassword(plaintextPassword) {
  if (!plaintextPassword || plaintextPassword.length < 8) {
    throw new Error('Password must be at least 8 characters.');
  }
  return bcrypt.hash(plaintextPassword, SALT_ROUNDS);
}

/**
 * Verify a login attempt against a stored hash.
 * @param {string} plaintextPassword  — what the user typed
 * @param {string} storedHash         — what's in the database
 * @returns {Promise<boolean>}
 */
async function verifyPassword(plaintextPassword, storedHash) {
  return bcrypt.compare(plaintextPassword, storedHash);
}

module.exports = { hashPassword, verifyPassword };
```

**Using it in a registration route:**

```js
// routes/auth.js
const express = require('express');
const router = express.Router();
const db = require('../db');                        // your database client
const { hashPassword, verifyPassword } = require('../auth/passwordUtils');

router.post('/register', async (req, res) => {
  const { email, password } = req.body;

  try {
    const existingUser = await db.users.findByEmail(email);
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered.' });
    }

    const passwordHash = await hashPassword(password);

    const newUser = await db.users.create({
      email,
      passwordHash,   // ← NEVER store the plaintext password
      role: 'viewer', // default role assigned at creation
      createdAt: new Date(),
    });

    return res.status(201).json({ userId: newUser.id });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
});

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  try {
    const user = await db.users.findByEmail(email);
    if (!user) {
      // Use a constant-time response — don't reveal whether the email exists
      return res.status(401).json({ error: 'Invalid credentials.' });
    }

    const isValid = await verifyPassword(password, user.passwordHash);
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid credentials.' });
    }

    // Issue a session token or JWT here (covered below)
    const token = issueToken(user);
    return res.json({ token });
  } catch (err) {
    return res.status(500).json({ error: 'Login failed.' });
  }
});

module.exports = router;
```

> **Warning:** Always return the same error message for "email not found" and "wrong password". Returning different messages enables *user enumeration attacks* — attackers can discover which emails are registered.

![Password hashing workflow diagram showing plaintext password flowing through bcrypt with a salt to produce a stored hash, and the verification path comparing a login attempt against the stored hash](https://i.ibb.co/Zr1R08N/fd496e8fb292.png)

### Choosing the Right Cost Factor

The bcrypt cost factor should be tuned so that hashing takes approximately **250–500ms on your production hardware**. Too fast means weak protection; too slow causes poor UX and server load.

```js
// Benchmark helper — run once when deploying to a new server
const bcrypt = require('bcrypt');

async function benchmarkCost(targetMs = 300) {
  for (let cost = 10; cost <= 14; cost++) {
    const start = Date.now();
    await bcrypt.hash('benchmark_password', cost);
    const elapsed = Date.now() - start;
    console.log(`Cost ${cost}: ${elapsed}ms`);
    if (elapsed >= targetMs) {
      console.log(`\n✅ Recommended cost factor: ${cost}`);
      break;
    }
  }
}

benchmarkCost();
```

Expected output on a mid-range server:
```
Cost 10: 80ms
Cost 11: 155ms
Cost 12: 310ms

✅ Recommended cost factor: 12
```

---

## Role-Based Access Control (RBAC)

### The Core Model

**RBAC** separates *who you are* from *what you can do* by introducing an intermediate layer: **roles**. Instead of assigning permissions directly to users, you assign permissions to roles, then assign roles to users.

```
User  ──assigned──▶  Role  ──has──▶  Permissions
```

This scales. With 10,000 users and 50 permission types, you'd need 500,000 direct mappings without roles. With 5 roles, you need at most 250 role-permission mappings — and users just get a role.

### Modeling Roles in a Database

```sql
-- PostgreSQL schema
CREATE TABLE roles (
  id   SERIAL PRIMARY KEY,
  name VARCHAR(50) UNIQUE NOT NULL  -- 'admin', 'moderator', 'editor', 'viewer'
);

CREATE TABLE permissions (
  id     SERIAL PRIMARY KEY,
  action VARCHAR(100) UNIQUE NOT NULL  -- 'posts:create', 'posts:delete', 'users:ban'
);

CREATE TABLE role_permissions (
  role_id       INT REFERENCES roles(id),
  permission_id INT REFERENCES permissions(id),
  PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE users (
  id            SERIAL PRIMARY KEY,
  email         VARCHAR(255) UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  role_id       INT REFERENCES roles(id) DEFAULT 4  -- default: 'viewer'
);
```

### RBAC Role Hierarchy Example

| Role | Permissions |
|---|---|
| **Admin** | All permissions: manage users, delete any content, change settings |
| **Moderator** | Edit/delete any post, ban users, cannot change system settings |
| **Editor** | Create, edit, delete *own* posts |
| **Viewer** | Read published content only |

![RBAC role hierarchy diagram showing Admin at the top with full permissions, Moderator and Editor in the middle with subset permissions, and Viewer at the bottom with read-only access](https://i.ibb.co/yBkXxKYt/9776eda8fbbf.png)

### Enforcing RBAC in Middleware

```js
// middleware/rbac.js
const db = require('../db');

/**
 * Factory that returns Express middleware requiring a specific permission.
 * Usage: router.delete('/posts/:id', requirePermission('posts:delete'), handler)
 */
function requirePermission(requiredPermission) {
  return async (req, res, next) => {
    const userId = req.user?.id; // set by your JWT/session middleware
    if (!userId) {
      return res.status(401).json({ error: 'Authentication required.' });
    }

    try {
      // Single query: join user → role → permissions
      const result = await db.query(
        `SELECT p.action
         FROM users u
         JOIN roles r ON u.role_id = r.id
         JOIN role_permissions rp ON r.id = rp.role_id
         JOIN permissions p ON rp.permission_id = p.id
         WHERE u.id = $1`,
        [userId]
      );

      const userPermissions = result.rows.map(row => row.action);

      if (!userPermissions.includes(requiredPermission)) {
        return res.status(403).json({
          error: `Forbidden. Required permission: ${requiredPermission}`,
        });
      }

      // Attach permissions to the request for downstream use
      req.permissions = userPermissions;
      next();
    } catch (err) {
      return res.status(500).json({ error: 'Authorization check failed.' });
    }
  };
}

module.exports = { requirePermission };
```

**Using the middleware:**

```js
// routes/posts.js
const { requirePermission } = require('../middleware/rbac');

router.post('/posts', requirePermission('posts:create'), createPostHandler);
router.delete('/posts/:id', requirePermission('posts:delete'), deletePostHandler);
router.post('/users/:id/ban', requirePermission('users:ban'), banUserHandler);
```

> **Tip:** Cache user permissions in the JWT payload or a short-lived Redis key (TTL 5 minutes) to avoid a database query on every request. Invalidate the cache when a user's role changes.

---

## OAuth 2.0: Delegated Authorization

### The Problem OAuth Solves

Imagine you build a calendar app that needs to read a user's Google Calendar. The naive approach: ask the user for their Google password. This is catastrophically bad. Your app now holds a credential that grants full Google account access, can't be scoped down, and can't be revoked without changing the password.

**OAuth 2.0** solves this with *delegated authorization*: the user grants your app limited, revocable access to a third-party service — without ever sharing their password with you.

### The Four Roles in OAuth

| Role | Description | Example |
|---|---|---|
| **Resource Owner** | The user who owns the data | You, the Google account holder |
| **Client** | The app requesting access | Your calendar app |
| **Authorization Server** | Issues tokens after the user consents | Google's OAuth server |
| **Resource Server** | Hosts the protected data | Google Calendar API |

### The Authorization Code Flow

This is the most secure OAuth flow for server-side applications:

```mermaid
sequenceDiagram
  participant User as Resource Owner
  participant App as Client App
  participant AS as Authorization Server
  participant API as Resource Server

  User->>App: Click "Connect Google Calendar"
  App->>AS: Redirect with client_id, scope, redirect_uri, state
  AS->>User: Show consent screen
  User->>AS: Grant permission
  AS->>App: Redirect back with authorization_code
  App->>AS: Exchange code for tokens (+ client_secret)
  AS->>App: access_token + refresh_token
  App->>API: GET /calendar/events (Bearer access_token)
  API->>App: Calendar data
```

### Implementing the OAuth Flow (Node.js / Express)

```js
// routes/oauth.js — Google OAuth 2.0 with Authorization Code flow
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const { URLSearchParams } = require('url');
const fetch = require('node-fetch');

const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'https://yourapp.com/oauth/google/callback';

// Step 1: Redirect user to Google's authorization server
router.get('/oauth/google', (req, res) => {
  // CSRF protection: generate a random state value
  const state = crypto.randomBytes(16).toString('hex');
  req.session.oauthState = state;

  const params = new URLSearchParams({
    client_id: GOOGLE_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',
    scope: 'openid email profile',  // OpenID Connect scopes
    state,
    access_type: 'offline',          // request refresh_token
    prompt: 'consent',
  });

  res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// Step 2: Handle the callback with the authorization code
router.get('/oauth/google/callback', async (req, res) => {
  const { code, state, error } = req.query;

  if (error) {
    return res.status(400).json({ error: `OAuth error: ${error}` });
  }

  // Validate state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'State mismatch. Possible CSRF attack.' });
  }

  // Step 3: Exchange authorization code for tokens
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
      grant_type: 'authorization_code',
    }),
  });

  const tokens = await tokenResponse.json();
  // tokens.access_token  — short-lived (1 hour)
  // tokens.refresh_token — long-lived, store securely
  // tokens.id_token      — OpenID Connect JWT (covered next)

  // Store tokens securely, create/find user in your DB
  await handleOAuthLogin(tokens, req, res);
});

module.exports = router;
```

> **Warning:** Never expose your `client_secret` to the browser. The token exchange **must** happen server-side. In Single Page Applications, use the **PKCE extension** (Proof Key for Code Exchange) to protect the flow without a client secret.

---

## OpenID Connect: Authentication on Top of OAuth

### What OIDC Adds

OAuth 2.0 answers: *"Can this app access this resource on your behalf?"* It is an **authorization** protocol. It was never designed to answer *"Who is this user?"* — but developers tried to use it that way anyway, creating inconsistent and insecure identity layers.

**OpenID Connect (OIDC)** is a thin identity layer built on top of OAuth 2.0. It adds one critical piece: the **ID Token** — a signed JWT that carries verified claims about the user's identity.

### The ID Token

When you include `openid` in your OAuth scope, the authorization server returns an `id_token` in addition to the `access_token`. This JWT contains:

```json
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "your-client-id.apps.googleusercontent.com",
  "exp": 1717200000,
  "iat": 1717196400,
  "email": "user@example.com",
  "email_verified": true,
  "name": "Jane Doe",
  "picture": "https://lh3.googleusercontent.com/..."
}
```

- `iss` — who issued the token (must match the expected issuer)
- `sub` — stable, unique user identifier (use this as your foreign key)
- `aud` — your client ID (verify this or risk token injection attacks)
- `exp` / `iat` — expiry and issued-at timestamps

### Verifying an ID Token

```js
// auth/verifyIdToken.js
const { OAuth2Client } = require('google-auth-library');

const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);

async function verifyGoogleIdToken(idToken) {
  const ticket = await client.verifyIdToken({
    idToken,
    audience: process.env.GOOGLE_CLIENT_ID,
  });

  const payload = ticket.getPayload();

  // The library validates signature, expiry, issuer, and audience for you
  return {
    googleId: payload.sub,
    email: payload.email,
    emailVerified: payload.email_verified,
    name: payload.name,
    avatarUrl: payload.picture,
  };
}

// Use it in your OAuth callback
async function handleOAuthLogin(tokens, req, res) {
  const identity = await verifyGoogleIdToken(tokens.id_token);

  // Find or create user by their stable Google subject ID
  let user = await db.users.findByGoogleId(identity.googleId);
  if (!user) {
    user = await db.users.create({
      googleId: identity.googleId,
      email: identity.email,
      name: identity.name,
      role: 'viewer',
    });
  }

  // Issue your own application session token
  const sessionToken = issueAppToken(user);
  res.cookie('session', sessionToken, { httpOnly: true, secure: true, sameSite: 'Strict' });
  res.redirect('/dashboard');
}
```

![OpenID Connect identity flow diagram comparing OAuth access token with OIDC ID token, showing how the ID token carries user identity claims like sub, email, and name](https://i.ibb.co/spw7CHgN/2557ed19e7f6.png)

### OIDC vs OAuth: The Critical Difference

| | OAuth 2.0 | OpenID Connect |
|---|---|---|
| **Purpose** | Authorization (access to resources) | Authentication (identity verification) |
| **Token issued** | `access_token` | `access_token` + `id_token` |
| **"Who are you?"** | ❌ Not designed for this | ✅ Yes — ID token carries identity claims |
| **Standard claims** | No standard user claims | `sub`, `email`, `name`, `picture`, etc. |
| **Used for SSO** | Partially | ✅ Primary use case |

---

## Modern Authentication Architecture

### Single Sign-On (SSO)

SSO lets users authenticate once and access multiple applications without re-entering credentials. OIDC is the modern standard for implementing SSO across web applications.

In an enterprise, an Identity Provider (IdP) like Okta, Auth0, or Azure AD acts as the central OIDC authorization server. All your internal apps delegate authentication to it. When a user signs into your HR portal, they're automatically recognized in your expense tracker — same `sub` claim, same identity.

### Token Lifecycle and Refresh

```js
// auth/tokenManager.js
const jwt = require('jsonwebtoken');

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;

function issueAppToken(user) {
  // Short-lived access token: 15 minutes
  const accessToken = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
      permissions: user.permissions,
    },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m', issuer: 'yourapp.com' }
  );

  // Long-lived refresh token: 7 days — store hash in DB for revocation
  const refreshToken = jwt.sign(
    { sub: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d', issuer: 'yourapp.com' }
  );

  return { accessToken, refreshToken };
}

async function refreshAccessToken(refreshToken) {
  let payload;
  try {
    payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
      issuer: 'yourapp.com',
    });
  } catch {
    throw new Error('Invalid or expired refresh token.');
  }

  // Check refresh token hasn't been revoked
  const isRevoked = await db.revokedTokens.exists(refreshToken);
  if (isRevoked) throw new Error('Refresh token revoked.');

  const user = await db.users.findById(payload.sub);
  return issueAppToken(user);
}

module.exports = { issueAppToken, refreshAccessToken };
```

---

## Trade-offs and Pitfalls

| Decision | Trade-off |
|---|---|
| bcrypt cost = 10 vs 14 | Faster login vs stronger brute-force resistance. Measure on prod hardware. |
| JWT vs server-side sessions | JWTs are stateless (can't revoke easily); sessions require storage but are revocable instantly. |
| Storing permissions in JWT | Reduces DB queries; stale permissions until token expiry or refresh. |
| Social login only | No password management overhead, but you're dependent on the IdP's availability. |
| Rolling refresh tokens | Better security (rotated on each use) vs complexity (handle race conditions in mobile apps). |

**Common mistakes to avoid:**
- Using MD5/SHA-1 for password storage in 2024
- Skipping `state` parameter validation in OAuth (CSRF)
- Not verifying `aud` claim in ID tokens (token injection)
- Storing JWTs in `localStorage` (XSS accessible) instead of `httpOnly` cookies
- Relying only on frontend route guards instead of enforcing authorization server-side

> **Warning:** Frontend-only authorization is not authorization. A user who knows the API endpoint can call it directly, bypassing any UI checks entirely. **Always enforce permissions on the server.**

---

## Security Best Practices

1. **Never store plaintext passwords.** Always use bcrypt, Argon2, or scrypt. Never MD5, SHA-1, or SHA-256 alone.
2. **Tune your bcrypt cost factor** so hashing takes 250–500ms on your server. Re-hash on login when you increase the cost factor.
3. **Use constant-time comparisons** — bcrypt.compare() already does this; never write your own string comparison for secrets.
4. **Apply the Principle of Least Privilege** — default new users to the lowest permission role. Require explicit elevation.
5. **Short-lived access tokens** (15 minutes) with long-lived refresh tokens (7 days) stored in httpOnly, Secure, SameSite=Strict cookies.
6. **Implement token revocation** — store a hash of issued refresh tokens in your database and check it on each refresh. Remove on logout.
7. **Validate OAuth state parameter** — always generate a cryptographically random state, store it in the session, and verify it on callback.
8. **Verify all JWT claims** — `iss`, `aud`, `exp`, `iat`. Use a well-maintained library; never decode without verifying the signature.
9. **Rate-limit login endpoints** — limit to 5–10 attempts per IP per minute. Use exponential backoff and account lockouts after repeated failures.
10. **Enable Multi-Factor Authentication (MFA)** — time-based one-time passwords (TOTP) with apps like Authy add a layer that survives password breaches.
11. **Audit your permission checks** — keep a list of every protected API route and the permission required. Review it in PRs.
12. **Rotate secrets regularly** — JWT signing keys, OAuth client secrets, and API keys should have rotation procedures.

---

## Conclusion

The security stack covered here isn't optional complexity — it's the minimum responsible baseline for any application that handles user data.

**Key takeaways:**
- **bcrypt** with a well-tuned cost factor is the only acceptable way to store passwords. Run the benchmark on your actual hardware.
- **Authentication and authorization are distinct problems** that require distinct solutions. Conflating them creates exploitable gaps.
- **RBAC** with roles, permissions, and a permission-checking middleware gives you a scalable, auditable authorization system.
- **OAuth 2.0** solves delegated authorization — letting users grant your app access to third-party resources without sharing passwords.
- **OpenID Connect** layers authenticated identity on top of OAuth. The `id_token`'s `sub` claim is the stable foreign key to build your user records around.
- **Short-lived JWTs + refresh tokens in httpOnly cookies** is the modern stateless session pattern that balances performance and security.

**Your immediate next step:** Audit your existing application against this checklist. Find your password storage, find your authorization checks, and verify they exist on the *server side* of every protected endpoint.

---

## Further Reading

- **OWASP Authentication Cheat Sheet** — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- **OAuth 2.0 RFC 6749** — https://datatracker.ietf.org/doc/html/rfc6749
- **OpenID Connect Core Specification** — https://openid.net/specs/openid-connect-core-1_0.html
- **bcrypt paper (Provos & Mazières, 1999)** — https://www.usenix.org/legacy/events/usenix99/provos.html
- **NIST Digital Identity Guidelines (SP 800-63B)** — https://pages.nist.gov/800-63-3/sp800-63b.html
