Most web applications still authenticate users by sending a password to a server and asking the server to compare it with a stored password hash.
This model is well established, but it has an important property that is easy to overlook:
the authentication server receives the user's password.
Even when TLS is used, the password exists in the server's memory during the authentication request. Password hashing protects stored credentials if a database is stolen, but it does not change the fundamental authentication flow.
There is another approach.
In this project, I built a complete authentication system using OPAQUE, a password-authenticated key exchange protocol designed so that the server never receives the user's password.
The stack is:
Next.js
TypeScript
Node.js
PostgreSQL
Docker
OPAQUE / RFC 9807
The result is a full-stack authentication system in which the password remains on the client while the server still gets cryptographic proof that the user knows it.
Why OPAQUE?
A traditional authentication flow usually looks like this:
Browser Server
| |
| email + password |
|----------------------------->|
| |
| password verification |
| |
|<-------- authenticated ------|
TLS protects the connection, but the server still receives the password.
A typical password-hash-based system therefore has two different security boundaries:
The password must be protected while travelling over the network.
The password must be protected while being processed by the server.
OPAQUE changes the model.
Instead of sending the password to the authentication server, the client executes a cryptographic protocol involving an Oblivious Pseudorandom Function (OPRF) and an authenticated key exchange.
Conceptually:
Browser Server
| |
| OPAQUE message |
|----------------------------->|
| |
| cryptographic response |
|<-----------------------------|
| |
| OPAQUE message |
|----------------------------->|
| |
| authenticated session |
The password itself never crosses the API boundary.
OPAQUE is standardized in RFC 9807.
The Architecture
The project uses Next.js as the application layer, PostgreSQL for persistent storage, and Docker for reproducible development and deployment.
The high-level architecture is:
┌──────────────────────┐
│ Browser │
│ │
│ Password │
│ OPAQUE client │
└──────────┬───────────┘
│
HTTPS / OPAQUE
│
▼
┌──────────────────────┐
│ Next.js │
│ │
│ Authentication API │
│ OPAQUE server │
│ Session management │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ PostgreSQL │
│ │
│ User records │
│ OPAQUE records │
│ Session hashes │
└──────────────────────┘
The most important design decision is that PostgreSQL does not contain a traditional password hash.
Instead, it stores the OPAQUE registration record generated by the client.
Registration
The registration process starts entirely in the browser.
The user enters:
Email
Password
The password is passed directly to the OPAQUE client implementation.
The browser generates an OPAQUE registration request:
Browser
│
│ password
│
│ OPAQUE registration
▼
RegistrationRequest
The registration request is sent to the server.
The server processes it using its OPAQUE server configuration and returns a registration response.
The browser then completes the registration process and generates the final registration record.
Conceptually:
Browser Server
password
│
│ startRegistration()
│
│ RegistrationRequest
├──────────────────────────────────►
│
│ RegistrationResponse
◄───────────────────────────────────┤
│
│ finishRegistration()
│
│ RegistrationRecord
├──────────────────────────────────►
│
│ PostgreSQL
│ stores record
The important detail is what doesn't happen:
POST /register
{
"email": "...",
"password": "..."
}
There is no such request.
The API receives OPAQUE protocol data instead.
What Does PostgreSQL Store?
The database contains a user record similar to:
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
opaque_record BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
There is no:
password
column.
There is also no traditional:
password_hash
column.
The opaque_record is cryptographic protocol data that allows the server to participate in future authentication attempts without learning the password.
The Login Flow
The login process is where OPAQUE becomes particularly interesting.
The browser starts an OPAQUE login using the password entered by the user.
The first message is KE1.
Browser Server
password
│
│ startLogin()
│
│ KE1
├─────────────────────────────►
│
│ KE2
◄─────────────────────────────┤
│
│ finishLogin()
│
│ KE3
├─────────────────────────────►
│
│ authenticated
The server uses the stored OPAQUE registration record and its server setup to generate the appropriate response.
The browser then completes the protocol.
If the password is correct, both sides derive the appropriate cryptographic session material.
If the password is incorrect, authentication fails.
At no point does the server need to receive the actual password.
OPAQUE Is Not Simply "Encrypting the Password"
This distinction is important.
A tempting implementation would be:
password
↓
encrypt
↓
send to server
That is not the goal.
The server would still receive encrypted password material, and the overall security properties would depend heavily on key management and the encryption scheme.
Another common idea is:
password
↓
SHA-256
↓
send hash
This is also not sufficient.
If the server accepts a static password hash as the authentication credential, that hash can effectively become a password-equivalent.
OPAQUE instead uses a password-authenticated key exchange protocol designed specifically for this problem.
Application Sessions
After a successful OPAQUE exchange, the application still needs an HTTP session.
I deliberately keep these two concepts separate.
OPAQUE handles the cryptographic authentication.
The application session handles normal web application authorization.
The flow therefore becomes:
OPAQUE
Browser ──────────────────► Server
authentication
↓
authenticated
↓
application session
↓
HttpOnly cookie
The project creates a cryptographically random session token.
The browser receives it through an HttpOnly cookie.
The database does not store the raw token.
Instead, it stores a SHA-256 hash:
random session token
↓
SHA-256
↓
PostgreSQL
This means a database leak does not directly reveal active session tokens.
Session Storage
The session table is intentionally simple:
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The cookie is configured with:
HttpOnly
Secure
SameSite
The Secure flag is enabled in production.
This prevents JavaScript from directly reading the session token and helps reduce the impact of client-side attacks.
Why PostgreSQL?
PostgreSQL is not responsible for the cryptographic protocol.
Its job is persistence.
It stores:
user identifiers
OPAQUE registration records
session hashes
expiration information
This separation keeps the architecture straightforward.
The cryptographic protocol runs at the application layer, while PostgreSQL provides durable storage.
Next.js API Routes
The project exposes separate endpoints for the different protocol stages.
Registration starts with:
POST /api/auth/register/start
The browser sends the OPAQUE registration request.
The second stage is:
POST /api/auth/register/finish
The browser sends the generated OPAQUE registration record.
Login follows the same idea:
POST /api/auth/login/start
and:
POST /api/auth/login/finish
There are also endpoints for:
POST /api/auth/logout
GET /api/auth/me
This makes the authentication protocol explicit instead of hiding everything behind a conventional email + password endpoint.
Docker
The development environment contains two services:
services:
postgres:
image: postgres:17-alpine
web:
build: .
PostgreSQL is isolated inside its own container.
The Next.js application runs in another container.
This makes the complete environment reproducible:
docker compose up --build
The application becomes available on:
http://localhost:3000
For development, PostgreSQL can also be run separately while Next.js runs directly with Node.js.
Generating the OPAQUE Server Setup
OPAQUE requires server-side cryptographic configuration.
The project includes a helper command:
npm run opaque:setup
The generated value is placed in:
OPAQUE_SERVER_SETUP=...
This value is a secret.
It should never be committed to Git.
In production, it should ideally be stored in a proper secret-management system or KMS rather than in a plain .env file.
Changing the server setup is also not something to do casually: existing OPAQUE records depend on the server configuration.
Rate Limiting
OPAQUE protects the password from being transmitted to the server, but it does not eliminate online password guessing.
An attacker can still repeatedly attempt authentication.
Therefore the API needs rate limiting.
The starter project includes a simple in-memory rate limiter:
IP / account
↓
rate limiter
↓
OPAQUE authentication
For production, this should be replaced with a distributed mechanism such as Redis.
This becomes particularly important when multiple application instances are running behind a load balancer.
Account Enumeration
Authentication systems often accidentally reveal whether an account exists.
For example:
"Email does not exist"
versus:
"Password is incorrect"
This allows attackers to build lists of valid accounts.
A production implementation should return deliberately generic authentication errors.
OPAQUE implementations can also use a fake record strategy to make nonexistent accounts behave more like existing accounts during the protocol.
This is an important part of designing the complete authentication system rather than simply implementing the cryptographic primitive.
Horizontal Scaling
There is one important limitation in the starter implementation.
During login, the server needs temporary state between the OPAQUE login messages.
The example implementation keeps that state in process memory.
That is fine for:
Browser
↓
Server A
but problematic for:
┌── Server A
Load Balancer│
└── Server B
The first request could reach Server A while the second reaches Server B.
For production, the ephemeral OPAQUE state should therefore be stored in a shared system such as Redis.
The architecture becomes:
Load Balancer
│
┌────────┴────────┐
▼ ▼
Next.js A Next.js B
│ │
└────────┬────────┘
▼
Redis
temporary state
The state should have a short expiration time.
Security Headers
The application also sets basic security headers, including:
X-Content-Type-Options
X-Frame-Options
Referrer-Policy
Permissions-Policy
Content-Security-Policy
Strict-Transport-Security
These headers are not part of OPAQUE itself.
They are simply additional layers of web application security.
A secure authentication protocol does not make the rest of the application secure automatically.
What OPAQUE Does Not Solve
OPAQUE is powerful, but it is not magic.
It does not protect against:
phishing
malware on the user's device
compromised browsers
weak passwords
malicious browser extensions
compromised TLS termination
stolen authenticated sessions
denial-of-service attacks
application authorization bugs
For example, if an attacker steals a user's active session cookie, they may be able to use the account without knowing the password.
That's why authentication needs to be considered as a complete security system.
Password Recovery Is Different
One particularly interesting consequence of this architecture is password recovery.
In a conventional application, an administrator can sometimes reset a password hash directly.
With OPAQUE, there is no ordinary password hash that the server can simply replace.
A password reset therefore requires a carefully designed recovery flow.
A production implementation should define how the user proves control of an independent recovery channel, such as an email address, and then establishes a new OPAQUE credential.
This is an important design area and should not be bolted on as an afterthought.
Adding Passkeys
OPAQUE and WebAuthn/passkeys solve related but different problems.
OPAQUE provides strong password-based authentication while keeping the password hidden from the server.
Passkeys eliminate the password from the authentication experience entirely and use public-key credentials.
A mature authentication platform can support both:
Authentication
│
┌─────────┴─────────┐
▼ ▼
OPAQUE Passkeys
password-based WebAuthn
Users who want password authentication can use OPAQUE, while users with compatible devices can use passkeys.
Project Structure
The final project is organized approximately as follows:
opaque-next-auth/
│
├── app/
│ ├── api/
│ │ └── auth/
│ │ ├── register/
│ │ ├── login/
│ │ ├── logout/
│ │ └── me/
│ │
│ ├── register/
│ ├── login/
│ └── account/
│
├── components/
│
├── lib/
│ ├── db.ts
│ ├── opaque.ts
│ ├── security.ts
│ ├── challenges.ts
│ └── rate-limit.ts
│
├── db/
│ └── 001_init.sql
│
├── scripts/
│
├── Dockerfile
├── docker-compose.yml
├── package.json
└── README.md
The goal is to keep the cryptographic layer, persistence layer, session layer, and UI clearly separated.
Running the Project
After cloning the project:
npm install
Generate the OPAQUE server setup:
npm run opaque:setup
Create the environment file:
cp .env.example .env
Set:
DATABASE_URL=postgres://app:app_dev_password_change_me@localhost:5432/opaque_auth
OPAQUE_SERVER_SETUP=YOUR_GENERATED_SETUP
APP_ORIGIN=http://localhost:3000
Start PostgreSQL:
docker compose up -d postgres
Run the migration:
npm run db:migrate
Then start Next.js:
npm run dev
Open:
http://localhost:3000
You can then create an account and inspect the network requests.
The most interesting thing to observe is that there is no HTTP request containing the user's password.
The most interesting aspect of OPAQUE is not simply that it uses advanced cryptography.
It changes the security boundary.
In a conventional password authentication system, the authentication server is trusted with the password during the login process.
With OPAQUE, the server can authenticate the user without receiving the password itself.
That does not eliminate every authentication risk, but it removes an important piece of sensitive information from the server-side application layer.
For applications where password privacy and resistance to server-side credential exposure are important, OPAQUE is therefore a technology worth considering.
The project described here provides a practical starting point using:
Next.js
+
TypeScript
+
OPAQUE
+
PostgreSQL
+
Docker
The next step toward a production deployment would be to add distributed challenge storage, robust rate limiting, account recovery, email verification, comprehensive integration tests, monitoring, secret management, and an independent security review.
Cryptography can provide strong primitives.
The real security of the system, however, still depends on how those primitives are integrated into the rest of the application.