Native email and password authentication on Cloudflare Workers

By

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

~~~

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:

EndpointJob
POST /api/registerCreate the user and start a session
POST /api/loginVerify the password and start a session
GET /api/meReturn the signed-in user
POST /api/logoutDelete 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:

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

Create the D1 database:

npx wrangler d1 create native-auth

Add the database binding and required secret to wrangler.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:

npx wrangler types

Create the tables

We need one table for users and one for sessions:

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:

openssl rand -base64 32

Put it in .dev.vars:

PASSWORD_PEPPER="PASTE_THE_RANDOM_VALUE_HERE"

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

Set a different production value using Wrangler:

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:

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:

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:

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:

{ "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:

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.

Send the original token using a hardened cookie:

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

Each attribute has a job:

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:

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:

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:

npx wrangler dev

Register a user and save the cookie:

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:

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:

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.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: