logo
How to Set Up Basic Authentication on Your VPS

How to Set Up Basic Authentication on Your VPS

Dec 23, 2025

Introduction: What is Basic Authentication and Why You Need It

When you deploy a web application, not all content should be publicly accessible. You might have:

  • An admin dashboard that only your team should access

  • A staging environment for testing before production

  • Internal tools (logs, metrics, deployment systems)

  • Temporary access for contractors or partners during a specific project

Without access control, anyone on the internet who finds these URLs can access them. Basic Authentication is the simplest way to protect these routes.

Basic Authentication (often called "Basic Auth") works by requiring visitors to enter a username and password before accessing protected content. It's the simplest form of password protection on the web.

Think of it like:

  • No authentication: Unlocked front door, anyone can walk in

  • Basic authentication: Locked front door, need a key (username/password) to enter

  • OAuth/JWT: Complex security system with multiple layers

Caddy makes Basic Authentication trivially simple—just a few lines of configuration. No plugins, no external dependencies, no complex setup.


How Basic Authentication Works

The Request Flow

User visits https://example.com/admin
    ↓
server checks: "Is /admin protected?"
    ↓
YES → Server asks for username/password
    ↓
Browser shows login popup:
    Username: [________]
    Password: [________]
    ↓
User enters credentials
    ↓
Browser sends username + password in HTTP header
    ↓
Server verifies: Is the password correct?
    ↓
YES → Allow access to /admin
NO → Show "Unauthorized" error

What Gets Sent to the Server

When you enter credentials in the Basic Auth popup, the browser automatically converts them to a format that's sent to the server.

Plain text version (what you see):

username: admin
password: MySecurePassword123!

What gets sent over the network (Base64 encoded):

Authorization: Basic YWRtaW46TXlTZWN1cmVQYXNzd29yZDEyMyE=

The browser Base64-encodes (not encrypts) the credentials. This is why Basic Auth always requires HTTPS—without encryption, anyone on the network can decode the Base64 and see your password.


Understanding Password Hashing

Caddy requires passwords to be stored as bcrypt hashes, not plain text. This is a critical security feature.

Plain Text vs. Hashed Passwords

❌ Storing plain text (DANGEROUS):

admin:MyPassword123
manager:BossPassword456

Problems:

  • If someone accesses your Caddyfile, they get all passwords

  • If your server is breached, all passwords are compromised

  • You can see (and misuse) employee passwords

✅ Storing bcrypt hashes (SECURE):

admin:$2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
manager:$2a$12$N3jZy8R2pLmXqWzYk9vP2.x7S8yH9Q3wKmL4T5rN6sU8vC1dE9g8q

Benefits:

  • Passwords are mathematically irreversible

  • Even if someone accesses the Caddyfile, they can't use the hashes

  • You can't recover or misuse passwords

  • If your server is breached, attackers get only useless hashes

How Bcrypt Works

Bcrypt is a special one-way encryption algorithm:

  1. Hashing: Password → Hash (one-way process, can't reverse)

    "MyPassword123" → $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
    
  2. Verification: Password is hashed again and compared to stored hash

    User enters: "MyPassword123"
    Caddy hashes it: $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
    Caddy compares: Does it match the stored hash?
    YES → Access granted
    NO → Access denied
    

Key point: The actual password is never stored or transmitted. Only the hash is stored.


Generating Secure Password Hashes

Using Caddy's Built-in Command

Caddy provides a convenient tool to generate bcrypt hashes:

caddy hash-password

Interactive process:

$ caddy hash-password

Enter password:
*** (your password is hidden as you type)

Confirm password:
*** (confirm by typing again)

Output:
$2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC

Copy this hash and paste it into your Caddyfile.

Generating Multiple Password Hashes

For multiple users:

# For admin user
$ caddy hash-password
Enter password: admin123
Output: $2a$12$abc123...

# For manager user
$ caddy hash-password
Enter password: manager456
Output: $2a$12$def456...

# For viewer user
$ caddy hash-password
Enter password: viewer789
Output: $2a$12$ghi789...

Then use them in your Caddyfile:

basicauth {
    admin $2a$12$abc123...
    manager $2a$12$def456...
    viewer $2a$12$ghi789...
}

Changing a Password

If someone leaves the team or a password is compromised:

  1. Generate a new hash: caddy hash-password

  2. Update the Caddyfile with the new hash

  3. Reload Caddy: caddy reload

  4. The old password no longer works


Use Cases and Real-World Scenarios

Use Case 1: Admin Dashboard

Your Node.js app has an admin panel at /admin that only your team should access:

example.com {
    handle_path /admin/* {
        basicauth {
            admin $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

What this does:

  • /admin/* routes require username: admin + password

  • All other routes are publicly accessible

  • No code changes needed in your Node.js app

Use Case 2: Staging Environment

Test your app before going to production without exposing it to the internet:

staging.example.com {
    basicauth {
        dev $2a$12$abc123...
        qa $2a$12$def456...
    }

    reverse_proxy localhost:3001
}

Your whole staging site is protected. Anyone needing access gets a username/password.

Use Case 3: Internal Tools

Keep your monitoring dashboard, logs, or deployment tools private:

monitoring.example.com {
    basicauth {
        monitor $2a$12$xyz789...
    }

    reverse_proxy localhost:9090
}

Only people with the password can see your monitoring system.

Use Case 4: Temporary Contractor Access

Give a contractor access for 3 months:

example.com {
    handle_path /contractor/* {
        basicauth {
            contractor $2a$12$temp123...
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

When the contract ends, remove the contractor user from the Caddyfile and reload.


Pattern 1: Basic Auth for Entire Domain

Protect all routes on a domain. Useful for staging environments or internal sites.

Simple Single-User Setup

example.com {
    basicauth {
        admin $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
    }

    reverse_proxy localhost:3000
}

What this requires:

  • Username: admin

  • Password: (whatever you hashed to get that long string)

Testing:

# Wrong password
$ curl https://example.com
# Response: Unauthorized

# Correct password
$ curl -u admin:MyPassword123 https://example.com
# Response: Your app's content

Multiple Users with Different Access Levels

internal.example.com {
    basicauth {
        admin $2a$12$abc123...
        manager $2a$12$def456...
        viewer $2a$12$ghi789...
    }

    reverse_proxy localhost:3000
}

All three users can access the site, but only with their own passwords. At this level, they all get the same access (Caddy doesn't differentiate). For role-based access (admin vs. viewer permissions), you'd handle that in your application code.

Development Team Access

dev.example.com {
    basicauth {
        dev1 $2a$12$abc123...
        dev2 $2a$12$def456...
        dev3 $2a$12$ghi789...
        qa $2a$12$jkl012...
    }

    reverse_proxy localhost:3000
}

Each team member gets their own username/password. They can still access the same content, but you can track who logged in (from logs).


Pattern 2: Route-Specific Basic Auth

Only protect certain paths. Most routes are public, but sensitive ones require authentication.

Protect Admin Panel Only

example.com {
    handle_path /admin/* {
        basicauth {
            admin $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Access behavior:

Public user visits:
  /products → ✅ Allowed (no auth)
  /about → ✅ Allowed (no auth)
  /admin → ❌ Blocked, asks for password
  /admin/users → ❌ Blocked, asks for password

Authenticated user visits (enters password):
  /admin → ✅ Allowed
  /admin/settings → ✅ Allowed

Protect Multiple Paths

example.com {
    # Admin panel protected
    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
        }
        reverse_proxy localhost:3000
    }

    # API for internal use protected
    handle_path /api/internal/* {
        basicauth {
            api $2a$12$def456...
        }
        reverse_proxy localhost:3000
    }

    # Health check endpoint protected
    handle_path /health {
        basicauth {
            monitor $2a$12$ghi789...
        }
        reverse_proxy localhost:3000
    }

    # Everything else public
    handle {
        reverse_proxy localhost:3000
    }
}

Access patterns:

RoutePublic?Auth Required
/products✅ YesNo
/about✅ YesNo
/admin/*❌ NoYes (admin user)
/api/internal/*❌ NoYes (api user)
/health❌ NoYes (monitor user)

Protect Specific Files

Protect individual routes within your app:

example.com {
    # Protect specific endpoints
    handle_path /logs {
        basicauth {
            admin $2a$12$abc123...
        }
        reverse_proxy localhost:3000
    }

    handle_path /metrics {
        basicauth {
            monitor $2a$12$def456...
        }
        reverse_proxy localhost:3000
    }

    # Public routes
    handle {
        reverse_proxy localhost:3000
    }
}

Pattern 3: Environment-Based Auth

Different authentication for different deployments.

Development with No Auth, Production with Auth

Don't protect dev (faster testing), but protect production:

# Development - no authentication
dev.example.com {
    reverse_proxy localhost:3000
}

# Staging - light authentication
staging.example.com {
    basicauth {
        dev $2a$12$abc123...
    }
    reverse_proxy localhost:3001
}

# Production - strong authentication
api.example.com {
    basicauth {
        admin $2a$12$def456...
        manager $2a$12$ghi789...
    }
    reverse_proxy localhost:3002
}

Different Credentials per Environment

# Dev environment - simple password
dev-api.example.com {
    basicauth {
        dev dev123
    }
    reverse_proxy localhost:3000
}

# Production environment - strong password
api.example.com {
    basicauth {
        admin $2a$12$StrongHashHereVeryLongString...
    }
    reverse_proxy localhost:3000
}

Real-World Scenarios

Scenario 1: E-Commerce with Public Shop and Admin Panel

shop.example.com {
    # Public shop - no authentication
    handle_path /products* {
        reverse_proxy localhost:3000
    }

    handle_path /checkout* {
        reverse_proxy localhost:3000
    }

    handle_path /account* {
        reverse_proxy localhost:3000
    }

    # Admin panel - protected
    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
            manager $2a$12$def456...
        }
        reverse_proxy localhost:3000
    }

    # Analytics - protected
    handle_path /analytics {
        basicauth {
            analyst $2a$12$ghi789...
        }
        reverse_proxy localhost:3000
    }

    # Everything else public
    handle {
        reverse_proxy localhost:3000
    }
}

Scenario 2: SaaS with Multi-Level Access

app.example.com {
    # Public landing page
    handle_path / {
        reverse_proxy localhost:3000
    }

    # User app - users authenticate via app login
    handle_path /dashboard* {
        reverse_proxy localhost:3000
    }

    # Admin panel - HTTP Basic Auth
    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
            support $2a$12$def456...
        }
        reverse_proxy localhost:3000
    }

    # Monitoring - HTTP Basic Auth
    handle_path /monitoring/* {
        basicauth {
            ops $2a$12$ghi789...
        }
        reverse_proxy localhost:3000
    }
}

Scenario 3: API with Public and Private Endpoints

api.example.com {
    # Public API endpoints (no auth)
    handle_path /api/v1/public/* {
        reverse_proxy localhost:3001
    }

    # Private API endpoints (Basic Auth)
    handle_path /api/v1/private/* {
        basicauth {
            partner1 $2a$12$abc123...
            partner2 $2a$12$def456...
            internal $2a$12$ghi789...
        }
        reverse_proxy localhost:3001
    }

    # Admin API (strict auth)
    handle_path /api/admin/* {
        basicauth {
            admin $2a$12$jkl012...
        }
        reverse_proxy localhost:3001
    }
}

Common Mistakes to Avoid

Mistake 1: Using Plain Text Passwords

# ❌ WRONG - Never do this!
basicauth {
    admin MyPassword123
    manager BossPassword456
}

Why it's dangerous:

  • Anyone with Caddyfile access gets all passwords

  • If the file is leaked, all passwords are compromised

  • You can see (and misuse) employee passwords

  • No security benefit

Solution: Always use hashed passwords:

# ✅ CORRECT
basicauth {
    admin $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
    manager $2a$12$N3jZy8R2pLmXqWzYk9vP2.x7S8yH9Q3wKmL4T5rN6sU8vC1dE9g8q
}

Mistake 2: Forgetting HTTPS

Basic Auth sends credentials in Base64 encoding (easily reversible, not encrypted). Without HTTPS, anyone on the network can intercept and decode your password.

# ❌ WRONG - HTTP only (insecure)
example.com {
    basicauth {
        admin $2a$12$abc123...
    }
    reverse_proxy localhost:3000
}

An attacker on the same WiFi can see:

Authorization: Basic YWRtaW46TXlQYXNzd29yZA==
# Decodes to: admin:MyPassword

Solution: Always use HTTPS:

# ✅ CORRECT - HTTPS (secure)
example.com {
    # Caddy auto-enables HTTPS and gets certificates
    basicauth {
        admin $2a$12$abc123...
    }
    reverse_proxy localhost:3000
}

Caddy automatically enables HTTPS for all domains.

Mistake 3: Protecting Too Much

Don't require authentication for routes that should be public:

# ❌ WRONG - Protects everything
example.com {
    basicauth {
        user $2a$12$abc123...
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Now users can't visit your site without a password, which:

  • Hurts SEO (search engines can't crawl)

  • Frustrates users

  • Makes sharing links impossible

Solution: Only protect what needs protection:

# ✅ CORRECT - Only protect admin
example.com {
    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Mistake 4: Same Password for Everyone

# ❌ WRONG - Can't tell who logged in
basicauth {
    user1 $2a$12$abc123...
    user2 $2a$12$abc123...  # Same hash!
    user3 $2a$12$abc123...  # Same hash!
}

Problems:

  • Can't track who accessed what

  • If one person's credentials are shared, can't revoke just theirs

  • Audit trail is useless

Solution: Unique passwords for each user:

# ✅ CORRECT
basicauth {
    admin $2a$12$abc123...    # Only for admin
    manager $2a$12$def456...  # Only for manager
    viewer $2a$12$ghi789...   # Only for viewer
}

Each user changes their password independently.

Mistake 5: Leaving Old Passwords Active

After someone leaves the company, leaving their account active:

# ❌ WRONG - Ex-employee still has access
basicauth {
    alice $2a$12$abc123...      # Left company 6 months ago
    bob $2a$12$def456...        # Currently employed
    charlie $2a$12$ghi789...    # Left yesterday
}

Solution: Remove accounts when people leave:

# ✅ CORRECT
basicauth {
    bob $2a$12$def456...        # Currently employed only
}

Or if they might need temporary access, immediately invalidate their password by:

  1. Removing the user from Caddyfile

  2. Running caddy reload

Mistake 6: Not Testing Auth Before Deploying

Deploy auth rules without testing, and you might lock yourself out:

# ❌ WRONG - You don't know what username/password works!
handle_path /admin/* {
    basicauth {
        admin $2a$12$abc123...
    }
    reverse_proxy localhost:3000
}

Solution: Test before deploying:

# Generate a password you know
$ caddy hash-password
Enter password: test123
Output: $2a$12$xyz789...

# Test locally
curl -u admin:test123 http://localhost/admin
# Should work!

# Then use in production

Viewing Access Logs

See who's accessing your protected routes:

Enable Logging in Caddy

example.com {
    log {
        output stdout
        format json
    }

    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

View Logs

# If running as service
sudo journalctl -u caddy -f

# If running in Docker
docker logs -f caddy-container-name

Look for lines like:

{
  "request": {
    "remote_ip": "203.0.113.10",
    "method": "GET",
    "uri": "/admin/users",
    "proto": "HTTP/2.0"
  },
  "resp_headers": {
    "Authorization": ["Basic ..."]
  },
  "status": 200
}

This shows who accessed /admin/users and when.


Changing Passwords

When a password is compromised or someone leaves:

Step 1: Generate a New Hash

$ caddy hash-password
Enter password: new_secure_password_123
Output: $2a$12$newHashHere...

Step 2: Update Caddyfile

basicauth {
    admin $2a$12$newHashHere...  # Updated
    manager $2a$12$def456...     # Unchanged
}

Step 3: Reload Caddy

caddy reload

Zero downtime: Current users stay connected, new users use the new password.

Revoking a User's Access

Simply remove their entry:

# ❌ BEFORE - Alice still has access
basicauth {
    alice $2a$12$abc123...
    bob $2a$12$def456...
}

# ✅ AFTER - Alice removed
basicauth {
    bob $2a$12$def456...
}

Then reload:

caddy reload

Alice's old password no longer works.


Combining Basic Auth with Other Security

Basic Auth + IP Filtering

Protect sensitive routes with both password and IP restrictions:

example.com {
    handle_path /admin/* {
        # Only from internal network
        @internal {
            remote_ip 10.0.0.0/8
        }

        handle @internal {
            # Also requires password
            basicauth {
                admin $2a$12$abc123...
            }
            reverse_proxy localhost:3000
        }

        respond "Access denied" 403
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Combined security:

  • Must be on internal network (IP filter)

  • AND must know the password (Basic Auth)

Basic Auth + Rate Limiting

Protect login routes with both password and rate limiting:

example.com {
    # Public login form
    handle_path /login {
        rate_limit {
            zone login {
                key {remote_host}
                events 10
                window 1m
            }
        }
        reverse_proxy localhost:3000
    }

    # Admin panel with password
    handle_path /admin/* {
        basicauth {
            admin $2a$12$abc123...
        }
        reverse_proxy localhost:3000
    }

    handle {
        reverse_proxy localhost:3000
    }
}

Complete Production-Ready Examples

Full Setup with Multiple Domains

# Production website - public
shop.example.com {
    reverse_proxy localhost:3000
}

# Admin panel - password protected
admin.example.com {
    basicauth {
        admin $2a$12$riW1ZFwQ6qxUoRpC6kGUo/Q7yMZeErZvm3Wv0bYl/fSfl.kkVGCzC
        manager $2a$12$N3jZy8R2pLmXqWzYk9vP2.x7S8yH9Q3wKmL4T5rN6sU8vC1dE9g8q
    }

    log {
        output stdout
        format json
    }

    reverse_proxy localhost:3001
}

# Staging environment - team access
staging.example.com {
    basicauth {
        dev $2a$12$abc123...
        qa $2a$12$def456...
        product $2a$12$ghi789...
    }

    reverse_proxy localhost:3002
}

# Monitoring - ops team only
monitoring.example.com {
    basicauth {
        ops $2a$12$jkl012...
    }

    log {
        output stdout
    }

    reverse_proxy localhost:9090
}

Mixed Authentication Setup

example.com {
    # Public routes - no auth
    handle_path /products* {
        reverse_proxy localhost:3000
    }

    handle_path /api/public/* {
        reverse_proxy localhost:3000
    }

    # Customer dashboard - app handles login
    handle_path /dashboard* {
        reverse_proxy localhost:3000
    }

    # Partner API - Basic Auth
    handle_path /api/partners/* {
        basicauth {
            partner1 $2a$12$abc123...
            partner2 $2a$12$def456...
        }
        reverse_proxy localhost:3000
    }

    # Internal admin - Basic Auth + IP filtering
    handle_path /admin/* {
        @internal {
            remote_ip 10.0.0.0/8 203.45.67.0/24
        }

        handle @internal {
            basicauth {
                admin $2a$12$ghi789...
            }
            reverse_proxy localhost:3000
        }

        respond "Admin access restricted" 403
    }

    # Monitoring - Basic Auth only
    handle_path /metrics {
        basicauth {
            monitor $2a$12$jkl012...
        }
        reverse_proxy localhost:3000
    }

    # Everything else public
    handle {
        reverse_proxy localhost:3000
    }
}

Security Best Practices

1. Use Strong Passwords

When generating password hashes, use strong passwords:

# ❌ WEAK - Easy to guess
$ caddy hash-password
Enter password: admin123

Result: Attackers can guess this in seconds.

# ✅ STRONG - Hard to guess
$ caddy hash-password
Enter password: Y8@kL#mN9pQ$xZ4&bC2!vW6*rT1%sF3

Result: Takes billions of years to guess.

2. Use Unique Usernames

Don't use generic names like "admin":

# ❌ WEAK - Standard username everyone tries
basicauth {
    admin $2a$12$abc123...
}

# ✅ STRONG - Unique username
basicauth {
    alice $2a$12$abc123...
    bob $2a$12$def456...
}

Usernames are sent in clear during authentication, so don't reveal what system they use (don't use "wordpress" for WordPress, "django" for Django, etc.).

3. Store Passwords Securely

Your Caddyfile contains password hashes. Protect it:

# Restrict Caddyfile permissions
sudo chmod 600 /etc/caddy/Caddyfile

# Only root and caddy user can read it
ls -l /etc/caddy/Caddyfile
# -rw------- 1 caddy caddy 2048 Jan 15 12:00 /etc/caddy/Caddyfile

4. Rotate Passwords Regularly

Change passwords monthly or quarterly:

# Generate new password
$ caddy hash-password
Enter password: new_password_2025_01

# Update Caddyfile
# Reload Caddy
caddy reload

5. Audit Access Logs

Regularly check who's accessing protected routes:

# Check logs for /admin access
sudo journalctl -u caddy | grep "/admin"

# Check for failed authentication attempts
sudo journalctl -u caddy | grep "401\|Unauthorized"

Key Takeaways

  1. Basic Auth is simple: Caddy makes it trivial to add password protection without any plugins.

  2. Always use hashed passwords: Use caddy hash-password to generate bcrypt hashes, never plain text.

  3. HTTPS is essential: Basic Auth only works safely with HTTPS encryption.

  4. Protect selectively: Only require auth for sensitive routes, keep public routes accessible.

  5. One password per user: Give each person their own username/password for tracking and revocation.

  6. Test before deploying: Verify auth works before pushing to production.

  7. Track access: Enable logging to audit who accesses protected routes.

  8. Change passwords regularly: Update passwords when people leave or periodically rotate them.

  9. Use strong passwords: Make passwords long and random, not easy to guess.

  10. Combine with other security: Layer Basic Auth with IP filtering and rate limiting for maximum protection.


Common Questions

Q: Is Basic Auth secure enough for production?

A: Basic Auth is secure when used with HTTPS. However, it's best for:

  • Internal tools

  • Staging environments

  • Temporary access

For public-facing apps, use OAuth 2.0 or session-based authentication in your application.

Q: Can I use Basic Auth with a database of users?

A: Not directly with Caddy's basicauth directive. For dynamic user management:

  • Use your application's built-in authentication

  • Or use OAuth 2.0 providers (Google, GitHub, etc.)

  • Or add a reverse proxy with authentication module

Q: How many users can I add?

A: Thousands, but it becomes unwieldy. Each user needs an entry in your Caddyfile:

basicauth {
    user1 $2a$12$...
    user2 $2a$12$...
    user3 $2a$12$...
    ...
}

For many users, use your application's authentication instead.

Q: What if I forget a password?

A: Users can't recover their password from the hash. Generate a new one:

$ caddy hash-password
Enter password: new_password
Output: $2a$12$newHashHere...

Update the Caddyfile and reload.


Further Reading