无密码登录正在成为Web身份认证的新趋势,Passkeys作为基于WebAuthn标准的实现方案,能够让用户在Node.js应用中彻底告别传统密码。其核心思路是使用一对非对称密钥,私钥安全保存在用户设备,公钥交由服务端存储,登录时通过签名挑战完成身份核验。

Passkeys与WebAuthn基础原理
WebAuthn是由W3C与FIDO联盟制定的浏览器API,Passkeys则是基于该标准、由操作系统和浏览器协同管理的可发现凭据。在注册阶段,服务端生成随机挑战值发给客户端,客户端调用 navigator.credentials.create() 生成密钥对,并将公钥、凭证ID回传。认证阶段则调用 navigator.credentials.get() 对服务端挑战进行签名,服务端用存储的公钥验证签名合法性。
这种机制下,用户无需记忆密码,也不存在密码在网络中传输的问题。即便服务端数据库泄露,攻击者也只能拿到公钥,无法逆向推导出私钥,因此从根本上抵御了撞库与钓鱼攻击。对于Node.js开发者来说,重点在于服务端如何正确生成挑战、校验响应以及持久化凭证信息。
服务端依赖与基础准备
在Node.js项目中,我们通常使用 @simplewebauthn/server 与 @simplewebauthn/browser 两个库来简化开发。服务端需要配置一个唯一的RP(依赖方)信息,包括ID和名称,同时准备一个持久层来保存用户与凭证。以下示例基于Express框架搭建基础结构。
const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');
const app = express();
app.use(express.json());
// RP配置,生产环境应使用真实域名
const rpName = 'Demo App';
const rpID = 'localhost';
const origin = 'http://localhost:3000';
// 模拟数据库
const users = {};
app.listen(3000, () => console.log('Server running'));
上面的代码完成了基础Express服务与RP参数的定义。注意 rpID 必须与前端访问的域名一致,否则浏览器会拒绝凭据操作。实际项目中,users 对象应替换为数据库表,字段至少包含用户标识、凭证ID与公钥。
实现注册流程
注册分为两步:首先服务端下发注册选项,包含挑战值与支持的认证器参数;随后客户端返回生成的凭证,服务端验证并保存。下面演示第一步接口。
app.post('/api/register/options', async (req, res) => {
const { username } = req.body;
const user = users[username] || { id: username, credentials: [] };
users[username] = user;
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(username),
userName: username,
attestationType: 'none',
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }
});
user.currentChallenge = options.challenge;
res.json(options);
});
该接口为每个用户生成包含随机挑战的注册选项,并将挑战暂存以备后续校验。residentKey 设为 required 表示创建可发现的Passkey,便于后续无用户名登录。客户端拿到选项后,调用浏览器API创建凭据。
第二步是验证客户端返回的注册响应,并提取公钥。代码如下:
app.post('/api/register/verify', async (req, res) => {
const { username, response } = req.body;
const user = users[username];
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: user.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID
});
if (verification.verified) {
const { credential } = verification.registrationInfo;
user.credentials.push({
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter
});
res.json({ status: 'ok' });
} else {
res.status(400).json({ error: '验证失败' });
}
});
验证通过后,我们将凭证ID与公钥存入用户记录。此后该用户便拥有了可用于登录的Passkey。需要注意,公钥是ArrayBuffer类型,实际存储时应转为Base64字符串或二进制字段。
实现登录认证流程
登录同样分两步:服务端生成认证挑战,客户端用私钥签名后回传,服务端校验签名与计数器。以下是下发认证选项的接口。
const { generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');
app.post('/api/login/options', async (req, res) => {
const { username } = req.body;
const user = users[username];
if (!user) return res.status(404).json({ error: '用户不存在' });
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user.credentials.map(c => ({ id: c.id })),
userVerification: 'preferred'
});
user.currentChallenge = options.challenge;
res.json(options);
});
该接口限定了允许使用的凭证列表,浏览器会从中匹配本地的Passkey。如果用户设备没有对应凭据,登录将失败,因此通常还需提供后备验证方式。接下来是验证签名环节。
app.post('/api/login/verify', async (req, res) => {
const { username, response } = req.body;
const user = users[username];
const cred = user.credentials.find(c => c.id === response.id);
if (!cred) return res.status(400).json({ error: '凭证无效' });
const result = await verifyAuthenticationResponse({
response,
expectedChallenge: user.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: cred.id,
credentialPublicKey: cred.publicKey,
counter: cred.counter
}
});
if (result.verified) {
cred.counter = result.authenticationInfo.newCounter;
res.json({ status: 'ok' });
} else {
res.status(400).json({ error: '认证失败' });
}
});
校验成功后更新签名计数器,用于检测克隆攻击。此时服务端可下发会话Cookie或JWT,完成无密码登录。整个过程中私钥始终未离开用户设备,服务端仅接触过公钥与挑战值。
部署与兼容性注意点
在生产环境,rpID必须配置为不含端口的正式域名,origin也要与实际访问地址完全匹配,否则主流浏览器会直接拦截WebAuthn调用。对于跨设备同步,Passkeys依托系统钥匙串(如iCloud钥匙串、谷歌密码管理器)实现多端可用,但开发者无需额外编码。
另一个常见误区是认为Passkeys可以完全取代所有旧账号体系。实际迁移时,建议保留邮件验证码作为后备,并支持用户将已有密码账户绑定Passkey。同时,服务端存储的公钥与凭证ID要做好索引,避免登录时全表扫描影响性能。
| 对比维度 | 传统密码 | Passkeys |
|---|---|---|
| 传输风险 | 有泄露可能 | 私钥不出设备 |
| 防钓鱼 | 弱 | 强 |
| 用户体验 | 需记忆输入 | 指纹人脸秒登 |
通过上述Node.js实践可以看出,接入Passkeys并不需要重写整个鉴权系统,只需在注册与登录环节嵌入WebAuthn挑战校验。随着生态成熟,无密码登录将成为提升安全与体验的标配方案。