Security is one of those things that developers often treat as an afterthought. You add a login form, check if the user is authenticated, and call it a day. But here's the uncomfortable truth: if you only protect your application at one layer, a single bypass breaks everything.
In this article, I'll explain the defense in depth approach - a security strategy that uses multiple layers of protection so that if one layer fails, the others still protect your data.
The Problem: Single Layer Security
Let's say you have an admin panel. You check if the user is an admin on the frontend and show/hide the admin menu accordingly:
// This is NOT security
{
user.isAdmin && <Link href="/admin">Admin Panel</Link>
}
tsx
Looks secure, right? Wrong. Here's what an attacker can do:
- Open browser DevTools
- Find where
isAdminis stored (localStorage, state, etc.) - Change
isAdmintotrue - Access everything
The frontend runs in the user's browser. They have complete control over it. Any check you do there is just a suggestion, not actual security.
┌─────────────────────────────────────────────────────────────────┐
│ SINGLE LAYER (DANGEROUS) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User ──► Frontend Check ──► Data │
│ │ │
│ └── "is_admin === true?" │
│ │
│ PROBLEM: Open DevTools, change is_admin to true, access all! │
│ │
└─────────────────────────────────────────────────────────────────┘
The Solution: Multi-Layer Security
With multiple layers, bypassing one layer doesn't help - the next layer still blocks you:
┌─────────────────────────────────────────────────────────────────┐
│ MULTI-LAYER (SECURE) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User ──► Frontend ──► Backend ──► Database ──► Data │
│ │ │ │ │
│ │ │ └── Security Rules │
│ │ │ │
│ │ └── Middleware Verification │
│ │ │
│ └── UI hiding (convenience only) │
│ │
│ To access data, attacker must bypass ALL THREE layers │
│ │
└─────────────────────────────────────────────────────────────────┘
Let's break down each layer and what it's responsible for.
Layer 1: Frontend (User Experience, NOT Security)
Purpose: Convenience and user experience
What it does:
- Shows/hides UI elements based on role
- Redirects unauthorized users away from protected pages
- Manages login state for a smooth UX
What it CANNOT do:
- Actually prevent access to data
- Stop a determined attacker
Think of it as: A door with a "Staff Only" sign. Polite people won't enter, but there's no lock.
// This is convenience, NOT security
{
user.isAdmin && <Link href="/admin/users">Manage Users</Link>
}
// Even with route guards - still not security
useEffect(() => {
if (!user.isAdmin) {
router.push("/dashboard")
}
}, [user])
tsx
The frontend layer is important for user experience. Regular users shouldn't see buttons they can't use. But never rely on it for actual protection.
Layer 2: Backend Middleware (Real Access Control)
Purpose: Actual access control and verification
What it does:
- Verifies authentication tokens are genuine (not forged)
- Fetches the actual role from the database (not what the frontend claims)
- Blocks requests from unauthorized users
- Returns 403 Forbidden if not authorized
Why it's secure:
- Runs on your server (attacker can't modify it)
- Uses cryptographic verification (can't fake tokens)
- Checks the real database (can't lie about roles)
Think of it as: A security guard who checks your ID against the employee database.
Here's an example with Laravel and Firebase authentication:
class VerifyAuthToken
{
public function handle($request, $next)
{
// 1. Get token from request header
$token = $request->bearerToken();
if (!$token) {
abort(401, 'No token provided');
}
// 2. Verify token cryptographically
// This checks the signature - can't be forged
$verifiedToken = $this->auth->verifyIdToken($token);
// 3. Get REAL role from database
// Not from the request, not from the token claims
$uid = $verifiedToken->claims()->get('sub');
$user = User::where('firebase_uid', $uid)->first();
// 4. Block if not authorized
if (!$user || !$user->is_admin) {
abort(403, 'Access denied');
}
return $next($request);
}
}
php
The key points here:
-
Token verification is cryptographic - The token is signed with a private key. You verify it with a public key. Without the private key, you can't forge a valid token.
-
Role comes from YOUR database - Never trust role information from the request body or token claims that users can manipulate. Always fetch the actual role from your database.
-
Runs on your server - The attacker has no way to modify this code or skip these checks.
Layer 3: Database Security Rules (Last Line of Defense)
Purpose: Database-level protection even if your backend is compromised
What it does:
- Enforces rules on every database read/write
- Runs inside the database service (attacker can't modify)
- Blocks direct database access attempts
Why it's secure:
- Runs on the database provider's servers
- Cannot be bypassed from the client
- Applies even if someone bypasses your app entirely
Think of it as: The vault door. Even if someone gets past the guard, they can't open the vault.
Here's an example with Firestore Security Rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper function to check if user is admin
function isAdmin() {
return get(/databases/$(database)/documents/users/$(request.auth.uid))
.data.role == 'admin';
}
// Users collection
match /users/{userId} {
// Users can read their own data, admins can read all
allow read: if request.auth.uid == userId || isAdmin();
// Only admins can modify user data
allow write: if isAdmin();
}
// Sensitive data collection
match /admin_data/{docId} {
// Only admins can read or write
allow read, write: if isAdmin();
}
}
}
javascript
For SQL databases, this translates to:
- Row-Level Security (PostgreSQL)
- Proper permission grants
- Never connecting with root/admin credentials from your app
How The Layers Work Together
Scenario: Legitimate Admin User
1. Admin logs in
└── Auth provider verifies credentials
└── Returns signed token
2. Admin clicks "View Users"
└── React sends request with token in header
3. Backend receives request
└── Middleware verifies token signature ✓
└── Fetches user from database ✓
└── Checks is_admin = true ✓
└── Allows request to proceed
4. Backend queries database
└── Database security rules check ✓
└── Returns user data
5. Admin sees user list ✅
Scenario: Attacker Trying to Bypass
ATTEMPT 1: Modify frontend JavaScript
───────────────────────────────────────
1. Attacker changes isAdmin to true in DevTools
2. React shows "Manage Users" link
3. Attacker clicks link, request goes to backend
4. Backend verifies token → User is NOT admin in database
5. Backend returns 403 Forbidden ❌
ATTEMPT 2: Forge a fake token
───────────────────────────────────────
1. Attacker creates fake JWT token
2. Sends request with fake token
3. Backend tries to verify signature
4. Signature doesn't match (no private key) ❌
5. Backend returns 401 Unauthorized
ATTEMPT 3: Claim admin status in request body
───────────────────────────────────────
1. Attacker sends { "is_admin": true } in request
2. Backend ignores the request body
3. Backend fetches REAL role from database
4. Database says user is NOT admin ❌
5. Backend returns 403 Forbidden
ATTEMPT 4: Direct database access
───────────────────────────────────────
1. Attacker tries to read admin_data directly
2. Database security rules check request
3. Rules verify: is this user an admin?
4. User is not admin ❌
5. Database returns permission denied
Token Verification: Why It Can't Be Faked
When a user logs in, they receive a JWT (JSON Web Token). This token contains information like:
{
"sub": "user-uid-here",
"email": "user@example.com",
"iat": 1234567890,
"exp": 1234571490
}
json
But the magic is in the signature. The token is signed using a private key that only your auth provider has:
Token Data + Private Key → Signature
Final Token: [header].[payload].[signature]
When your backend receives a token, it verifies the signature using the public key:
Token Signature + Public Key → Valid or Invalid?
To forge a token, an attacker would need:
- The private key (impossible to get)
- Or to break the cryptographic algorithm (mathematically infeasible)
This is why you should never put sensitive information like roles in the token payload if users can modify their own tokens. Always verify roles against your database.
Common Security Mistakes
1. Trusting Frontend Data
// ❌ BAD: Trusting role from request
$isAdmin = $request->input('is_admin');
// ✅ GOOD: Fetching role from database
$isAdmin = $user->is_admin;
php
2. Only Frontend Checks
// ❌ BAD: Only frontend check, no backend protection
if (user.isAdmin) {
showAdminPanel()
}
// ✅ GOOD: Frontend check + backend middleware
// Frontend shows/hides UI
// Backend has middleware that actually blocks unauthorized requests
tsx
3. Storing Secrets in Frontend Code
// ❌ BAD: API keys in frontend code
const apiKey = "sk_live_abc123..."
// ✅ GOOD: API keys only on backend
// Frontend calls your backend, backend uses the key
javascript
4. No Token Expiration
// ❌ BAD: Tokens that never expire
$token = JWT::encode($payload, $key);
// ✅ GOOD: Short-lived tokens with refresh mechanism
$payload['exp'] = time() + 3600; // 1 hour
$token = JWT::encode($payload, $key);
php
Security Checklist
Before going to production, verify:
- All admin routes have backend middleware protection
- All API endpoints verify authentication tokens
- Role information comes from database, not request
- Database has security rules or row-level security
- API keys and secrets are not in frontend code
- Tokens have reasonable expiration times
- HTTPS is enabled everywhere
- CORS is configured properly
Summary
| Layer | Location | Purpose | Can Be Bypassed? |
|---|---|---|---|
| Frontend | Browser | UX convenience | Yes (DevTools) |
| Backend | Your Server | Access control | No |
| Database | DB Server | Data protection | No |
The key principle: Never trust the client. Always verify on the server.
Your frontend is for user experience. Your backend is for security. Your database rules are your last line of defense. Use all three, and even if one layer has a bug or gets compromised, the others will still protect your data.
Security isn't a feature you add at the end. It's a mindset you build with from the start.