# Native email and password authentication on Cloudflare Workers

> Build native email/password auth on Cloudflare Workers with peppered PBKDF2 hashes, D1 sessions, and hardened HttpOnly cookies.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-10 | Updated: 2026-08-21 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/native-email-password-authentication-cloudflare-workers/

You can build email and password authentication on Cloudflare without running a separate auth server.

Workers give us the Web Crypto API. D1 gives us a SQL database. The browser manages the session cookie.

We are going to connect those pieces.

The interesting part is not the routing. It is deciding what we **never store**.

We never store passwords. We store salted, peppered PBKDF2 hashes.

We never store usable session tokens either. We store SHA-256 hashes of those tokens.

If someone copies the D1 database, they cannot use either value to log in.

## What we are building

Our Worker needs four endpoints:

| Endpoint | Job |
| --- | --- |
| `POST /api/register` | Create the user and start a session |
| `POST /api/login` | Verify the password and start a session |
| `GET /api/me` | Return the signed-in user |
| `POST /api/logout` | Delete the session |

I am not going to paste an entire Worker into this article.

Most of that code would be request parsing, routing, and error handling. Instead, we will focus on the parts that are easy to get wrong: passwords, sessions, cookies, and request protection.

## Create the Worker and the database

Create a TypeScript Worker:

```bash
npm create cloudflare@latest -- native-auth-worker
cd native-auth-worker
```

Create the D1 database:

```bash
npx wrangler d1 create native-auth
```

Add the database binding and required secret to `wrangler.jsonc`:

```jsonc
{
  "name": "native-auth-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-30",
  "secrets": {
    "required": ["PASSWORD_PEPPER"]
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "native-auth",
      "database_id": "YOUR_DATABASE_ID"
    }
  ]
}
```

Generate the binding types:

```bash
npx wrangler types
```

## Create the tables

We need one table for users and one for sessions:

```sql
create table users (
  id text primary key,
  email text not null collate nocase unique,
  password_hash text not null,
  created_at integer not null
);

create table sessions (
  token_hash text primary key,
  user_id text not null references users(id) on delete cascade,
  expires_at integer not null,
  created_at integer not null
);

create index sessions_expires_at_idx on sessions(expires_at);
```

Notice that there is no `password` column and no `session_token` column.

That is intentional.

## Add the password pepper

A **salt** is random and unique for every password. We store it with the password hash.

A **pepper** is shared by the application. We keep it outside D1 as an encrypted Worker secret.

Generate a local pepper:

```bash
openssl rand -base64 32
```

Put it in `.dev.vars`:

```text
PASSWORD_PEPPER="PASTE_THE_RANDOM_VALUE_HERE"
```

Make sure `.dev.vars*` is in `.gitignore`.

Set a different production value using Wrangler:

```bash
npx wrangler secret put PASSWORD_PEPPER
```

Never put the pepper in D1 or `wrangler.jsonc`.

Keep it stable too. If you lose it, you can no longer verify existing passwords.

## Hash the password

The password goes through two operations:

1. HMAC-SHA256 transforms it using the secret pepper.
2. PBKDF2 derives the stored hash using a random salt.

This is the essential code:

```ts
const salt = crypto.getRandomValues(new Uint8Array(16))

const pepperKey = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode(env.PASSWORD_PEPPER),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign'],
)

const pepperedPassword = await crypto.subtle.sign(
  'HMAC',
  pepperKey,
  new TextEncoder().encode(password),
)

const passwordKey = await crypto.subtle.importKey(
  'raw',
  pepperedPassword,
  'PBKDF2',
  false,
  ['deriveBits'],
)

const hash = await crypto.subtle.deriveBits(
  {
    name: 'PBKDF2',
    hash: 'SHA-256',
    salt,
    iterations: 600_000,
  },
  passwordKey,
  256,
)
```

Store the result as one versioned string:

```text
pbkdf2_sha256_peppered$600000$SALT$HASH
```

Encode the salt and hash using base64url before joining the four values.

The algorithm name and iteration count give us an upgrade path. A future login can recognize an older format, verify it once, and replace it with a stronger record.

## Validate the password before hashing it

Password hashing is intentionally expensive. Reject invalid input before spending that CPU time.

For a password-only login, I would require at least 15 characters and allow at least 64. Do not require one uppercase letter, one number, and one symbol. Those composition rules produce predictable passwords and make passphrases harder to use.

Also check new passwords against a blocklist of common and breached values. A long password is not useful if it appears in every attacker's dictionary.

Put a generous maximum on the UTF-8 byte length too. Do not silently truncate. This bounds the work performed before PBKDF2 and avoids turning a giant password body into a CPU denial-of-service input.

## Why use both a salt and a pepper?

The salt stops an attacker from reusing the same precomputed password table for every account.

The pepper protects against a D1-only leak. The attacker needs both the database and the Worker secret before they can test guesses.

The pepper does not replace a strong password hash. If both D1 and the secret leak, the attacker can still perform an offline attack.

OWASP currently recommends at least 600,000 PBKDF2-HMAC-SHA256 iterations.

Do not weaken the password hash to fit the Free Workers CPU limit. Use Paid Workers with an appropriate CPU budget, or use a managed authentication service.

Benchmark the complete login flow on the deployed Worker. Record the work factor with each hash so you can raise it when the guidance changes.

## Verify a password

Login reverses none of this. Password hashes are one-way.

Instead, split the stored record, decode its salt and hash, then run the submitted password through the same HMAC and PBKDF2 steps.

Compare the two fixed-size hashes using Cloudflare's timing-safe comparison:

```ts
const valid = crypto.subtle.timingSafeEqual(
  candidateHash,
  storedHash,
)
```

Do not compare encoded hash strings with `===`.

If the email does not exist, still run one password derivation using a dummy salt and hash. Otherwise, unknown emails return much faster and reveal which addresses have accounts.

Return the same error for both cases:

```json
{ "error": "Invalid email or password" }
```

The status code, response size, and broad timing should match too. A generic JSON message does not help if an unknown account returns `404` in 5 ms and a known account returns `401` after the password hash finishes.

Rate-limit login before running PBKDF2. Use two independent limits: one attached to the account identifier and one attached to a client or network signal. An IP-only limit can block a school or office. An account-only limit lets an attacker spread guesses across many addresses.

Return a generic `429` when either limit is reached. Add increasing delays or temporary lockouts carefully so an attacker cannot permanently lock another person's account.

## Create a session

After registration or login, generate 32 random bytes for the session token.

The browser receives the token. D1 receives only its SHA-256 hash:

```ts
const token = crypto.getRandomValues(new Uint8Array(32))

const tokenHash = await crypto.subtle.digest(
  'SHA-256',
  token,
)
```

Encode the original token as base64url before putting it in the cookie. Store the encoded hash in the `sessions` table with the user ID and expiry time.

Every successful login gets a new token. If the application had an anonymous or pre-authentication session, destroy it instead of upgrading it in place. Generate another token after a privilege change, and invalidate other sessions after a password reset unless the product explicitly lets the user keep them.

Why is fast SHA-256 enough here?

Passwords are human input and often guessable. Session tokens contain 256 random bits generated by us. An attacker cannot realistically guess them.

## Set the session cookie

Send the original token using a hardened cookie:

```http
Set-Cookie: __Host-session=TOKEN; Path=/; HttpOnly; Secure; SameSite=Lax
```

Each attribute has a job:

- `HttpOnly` stops JavaScript from reading the token.
- `Secure` sends it only over HTTPS.
- `SameSite=Lax` blocks many cross-site requests.
- `Path=/` makes it available to the whole application.
- `__Host-` prevents a sibling subdomain from replacing it.

Do not put the session token in `localStorage`. Browser JavaScript can read it there.

## Authenticate a request

For `GET /api/me`, read the cookie and hash its value.

Use that hash to find a live session:

```sql
select users.id, users.email
from sessions
join users on users.id = sessions.user_id
where sessions.token_hash = ?1
  and sessions.expires_at > ?2
```

If the query returns no row, respond with `401`.

Logout performs the opposite operation. Hash the cookie value, delete that session row, and return an expired cookie.

Delete expired sessions periodically too. A daily Cron Trigger is enough for this small system.

## Protect state-changing requests

Cookies are sent automatically by the browser. This makes CSRF our problem.

For registration, login, and logout, compare the `Origin` header with the Worker's origin:

```ts
const origin = request.headers.get('Origin')
const expectedOrigin = new URL(request.url).origin

if (origin !== expectedOrigin) {
  return new Response('Cross-origin request rejected', { status: 403 })
}
```

Also accept only `application/json`. A normal cross-site HTML form cannot send that content type.

Treat `SameSite` and the JSON content type as extra layers, not the complete defense. If your deployment cannot require a trustworthy `Origin` or `Referer`, use a real CSRF token. You can also reject `Sec-Fetch-Site: cross-site` before doing any authentication work, with origin verification as the fallback for clients that do not send Fetch Metadata headers.

If your frontend and API use different origins, define an explicit list of trusted origins. Do not replace this check with `*`.

## Test the flow

Start the Worker:

```bash
npx wrangler dev
```

Register a user and save the cookie:

```bash
curl -i -c cookies.txt \
  -H 'Content-Type: application/json' \
  -H 'Origin: http://localhost:8787' \
  --data '{"email":"ada@example.com","password":"correct horse battery staple"}' \
  http://localhost:8787/api/register
```

Then send the cookie to the protected endpoint:

```bash
curl -i -b cookies.txt http://localhost:8787/api/me
```

Inspect D1 after the request. You should find a versioned password record and a hashed session token. You should not find the password or cookie token.

## Before using this in production

This is the small cryptographic core of an authentication system.

A real product also needs:

- email verification
- password reset with short-lived, single-use tokens
- rate limiting by account and client signal
- bot protection where abuse justifies it
- session listing and revocation
- session invalidation after password changes
- safe logging that excludes passwords and tokens
- tests for malformed, expired, and cross-origin requests

Registration and recovery need the same enumeration protection as login.

Do not answer registration with "this email already exists" while an unknown address gets a different response. Return one neutral message such as "Check your email to continue." If the account exists, you can send a private notification or sign-in link to its owner.

Do not grant verified-user privileges merely because registration inserted a row. Start with an unverified account state, make verification tokens short-lived and single-use, and change that state only after the person proves control of the address.

For a product that needs all of this immediately, I would use a well-maintained authentication library.

But now the important part is no longer mysterious.

Passwords become slow, salted hashes that also depend on a server secret. Sessions become random browser tokens represented by one-way hashes in D1.

Those two rules give the system its foundation.
