Hono 工程化实战 04:登录、Session 和 Refresh Token 设计
wxk1991 Lv5

Hono 工程化实战 04:登录、Session 和 Refresh Token 设计

登录系统看起来简单。

用户输入账号密码,后端返回 token,前端存起来,之后请求带上 token。

但生产里,登录系统很容易写出安全坑:

1
2
3
4
5
6
access token 有效期太长
refresh token 不轮换
退出登录只是前端删 token
cookie 没有 HttpOnly
权限变更后旧 token 继续有效
多设备登录没法管理

Hono 不会替你规定认证模型。

这很好,因为你很自由。

但这也意味着你要自己把登录、会话、刷新、退出、权限变化设计清楚。

这篇文章讲 Hono 前后端分离项目里一套比较稳的认证设计。

技术栈快照时间:2026-06-23。Hono Cookie Helper、JWT Helper、JWT Middleware 相关 API 会更新,生产使用前以官方文档为准。


一、先区分三个概念

很多认证设计混乱,是因为把这三个东西混在一起:

1
2
3
用户身份
访问令牌
会话状态

用户身份是:

1
2
3
4
userId
email
role
tenant memberships

访问令牌是:

1
2
3
access token
短期有效
用于访问 API

会话状态是:

1
2
3
4
5
refresh token
device id
登录时间
过期时间
是否撤销

生产系统里,不要只靠一个长期 JWT 解决所有问题。

长期 JWT 最大的问题是撤销困难。


二、推荐的前后端分离模型

我更推荐:

1
2
3
4
5
access token:短期,比如 10-30 分钟
refresh token:长期,比如 7-30 天
refresh token 存服务端会话表
refresh token 使用 HttpOnly Cookie
access token 可以放内存

流程:

1
2
3
4
5
6
7
8
1. 用户登录
2. 后端验证账号密码
3. 创建 session 记录
4. 返回 access token
5. 设置 refresh token cookie
6. access token 过期后,前端调用 refresh 接口
7. 后端校验 refresh token,轮换 session token
8. 返回新的 access token

这样做的好处:

1
2
3
4
5
access token 泄露窗口短
refresh token 可以撤销
退出登录可以失效服务端 session
多设备登录可以管理
权限变化可以控制刷新策略

三、session 表设计

可以先从这张表开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
create table sessions (
id text primary key,
user_id text not null,
token_hash text not null,
user_agent text,
ip text,
revoked_at text,
expires_at text not null,
created_at text not null,
updated_at text not null
);

create index idx_sessions_user_id on sessions (user_id);
create index idx_sessions_token_hash on sessions (token_hash);

注意存 token_hash,不要明文存 refresh token。

如果数据库泄露,明文 refresh token 会直接变成登录凭证。

hash 可以这样做:

1
2
3
4
5
6
7
8
9
10
async function sha256(input: string) {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(input)
)

return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

四、登录接口

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { Hono } from 'hono'
import { setCookie } from 'hono/cookie'
import { sign } from 'hono/jwt'

const app = new Hono<{ Bindings: Env }>()

app.post('/api/auth/login', async (c) => {
const input = await c.req.json()
const user = await verifyPassword(input.email, input.password, c.env.DB)

if (!user) {
return c.json({
error: {
code: 'INVALID_CREDENTIALS',
message: '邮箱或密码错误',
},
}, 401)
}

const sessionId = crypto.randomUUID()
const refreshToken = crypto.randomUUID()
const now = new Date()
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)

await c.env.DB.prepare(`
insert into sessions (
id, user_id, token_hash, user_agent, ip,
expires_at, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
sessionId,
user.id,
await sha256(refreshToken),
c.req.header('user-agent') ?? '',
c.req.header('cf-connecting-ip') ?? '',
expiresAt.toISOString(),
now.toISOString(),
now.toISOString()
).run()

const accessToken = await sign({
sub: user.id,
sid: sessionId,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
}, c.env.JWT_SECRET)

setCookie(c, 'refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/api/auth/refresh',
expires: expiresAt,
})

return c.json({
data: {
accessToken,
user: {
id: user.id,
email: user.email,
},
},
})
})

这里有几个关键点:

1
2
3
4
5
refresh token 放 HttpOnly cookie
cookie path 限制在 refresh 接口
access token 有短有效期
session 存服务端
refresh token 只存 hash

五、为什么 refresh token 要轮换

刷新接口不要一直复用同一个 refresh token。

更好的做法是每次刷新都轮换:

1
2
3
4
旧 refresh token 用一次就失效
后端生成新的 refresh token
更新 session token_hash
重新设置 cookie

这样即使旧 token 泄露,也会更快失效。

刷新接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { getCookie, setCookie } from 'hono/cookie'

app.post('/api/auth/refresh', async (c) => {
const oldToken = getCookie(c, 'refresh_token')

if (!oldToken) {
return c.json({
error: {
code: 'REFRESH_TOKEN_REQUIRED',
message: '请重新登录',
},
}, 401)
}

const tokenHash = await sha256(oldToken)
const session = await findSessionByTokenHash(tokenHash, c.env.DB)

if (!session || session.revoked_at || new Date(session.expires_at) < new Date()) {
return c.json({
error: {
code: 'SESSION_EXPIRED',
message: '登录已过期,请重新登录',
},
}, 401)
}

const newRefreshToken = crypto.randomUUID()
const now = new Date().toISOString()

await c.env.DB.prepare(`
update sessions
set token_hash = ?, updated_at = ?
where id = ?
`).bind(
await sha256(newRefreshToken),
now,
session.id
).run()

const accessToken = await sign({
sub: session.user_id,
sid: session.id,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
}, c.env.JWT_SECRET)

setCookie(c, 'refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/api/auth/refresh',
expires: new Date(session.expires_at),
})

return c.json({
data: { accessToken },
})
})

这比“refresh token 永不过期、永不变化”安全得多。


六、鉴权 middleware

API 请求带 access token:

1
Authorization: Bearer <access-token>

middleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { verify } from 'hono/jwt'
import type { Context, Next } from 'hono'

type Variables = {
user: {
id: string
}
sessionId: string
}

async function auth(c: Context<{ Bindings: Env; Variables: Variables }>, next: Next) {
const authorization = c.req.header('Authorization')

if (!authorization?.startsWith('Bearer ')) {
return c.json({
error: {
code: 'AUTH_REQUIRED',
message: '请先登录',
},
}, 401)
}

try {
const token = authorization.slice('Bearer '.length)
const payload = await verify(token, c.env.JWT_SECRET)

c.set('user', { id: String(payload.sub) })
c.set('sessionId', String(payload.sid))

await next()
} catch {
return c.json({
error: {
code: 'INVALID_ACCESS_TOKEN',
message: '登录状态无效',
},
}, 401)
}
}

如果你的权限经常变化,不要把所有权限都塞进 access token。

可以只放:

1
2
3
sub
sid
exp

权限在请求时查数据库或缓存。


七、退出登录要撤销 session

退出登录不应该只是前端删 token。

后端也要撤销 session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { deleteCookie } from 'hono/cookie'

app.post('/api/auth/logout', auth, async (c) => {
const sessionId = c.get('sessionId')

await c.env.DB.prepare(`
update sessions
set revoked_at = ?, updated_at = ?
where id = ?
`).bind(
new Date().toISOString(),
new Date().toISOString(),
sessionId
).run()

deleteCookie(c, 'refresh_token', {
path: '/api/auth/refresh',
})

return c.json({ data: { ok: true } })
})

注意 access token 可能还会在短时间内有效。

所以 access token 的有效期不要太长。

如果你必须立即失效 access token,就需要在鉴权时检查 session 是否 revoked。

这会多一次查询。

安全和性能要权衡。


八、多设备登录

有了 sessions 表,就能做多设备管理。

例如查询当前用户所有登录设备:

1
2
3
4
5
6
select id, user_agent, ip, created_at, updated_at, expires_at
from sessions
where user_id = ?
and revoked_at is null
and expires_at > ?
order by updated_at desc;

用户可以手动踢掉某个设备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.delete('/api/auth/sessions/:id', auth, async (c) => {
const user = c.get('user')
const sessionId = c.req.param('id')

await c.env.DB.prepare(`
update sessions
set revoked_at = ?, updated_at = ?
where id = ? and user_id = ?
`).bind(
new Date().toISOString(),
new Date().toISOString(),
sessionId,
user.id
).run()

return c.json({ data: { ok: true } })
})

这是长期 JWT 很难优雅处理的需求。


九、Cookie 和 CORS 要一起考虑

如果 refresh token 用 cookie,前后端分离时要注意 CORS。

后端 CORS:

1
2
3
4
5
6
app.use('/api/*', cors({
origin: 'https://app.example.com',
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
}))

前端请求 refresh:

1
2
3
4
await fetch('https://api.example.com/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})

如果 credentials 没配好,cookie 不会带上。

如果 CORS origin 写成 *,带凭证请求也会出问题。

认证系统里的 CORS 不要靠猜。


如果你的前端和 API 在同一个主站域名下,也可以用纯 Cookie Session。

比如:

1
2
app.example.com
app.example.com/api

这时可以让所有 API 都通过 HttpOnly session cookie 认证。

优点:

1
2
3
前端不用管理 access token
XSS 直接偷 token 的风险降低
服务端撤销简单

缺点:

1
2
3
CSRF 要更认真处理
跨域移动端/第三方接入不如 Bearer token 灵活
水平扩展时需要共享 session 存储

没有绝对正确。

如果是 SPA + 独立 API + 移动端共用,我更偏向 access token + refresh cookie。

如果是传统 Web 应用,纯 cookie session 很舒服。


十一、认证设计清单

上线前至少检查:

1
2
3
4
5
6
7
8
9
10
access token 是否短期有效
refresh token 是否可撤销
refresh token 是否只存 hash
refresh token 是否轮换
cookie 是否 HttpOnly/Secure/SameSite
退出登录是否撤销服务端 session
密码错误是否有统一错误,不暴露账号是否存在
登录接口是否限流
权限变化后旧 token 怎么处理
多设备登录是否可管理

登录系统不是“能登录”就完事。

它要能安全地登录、刷新、退出、撤销、追踪。

Hono 给你的认证工具足够轻。

真正决定安全性的,是你对会话生命周期的设计。