Login, JWT, OAuth2, refresh token, logoutLogin, JWT, OAuth2, refresh tokens, logout
Trình duyệt chỉ nói chuyện với BFF. BFF giữ refresh token trong cookie HttpOnly, chuyển yêu cầu đăng nhập sang Auth Service hoặc sang nhà cung cấp OIDC. Order API kiểm tra chữ ký JWT bằng khoá công khai (JWKS). Bật “Access token hết hạn” để xem xoay vòng refresh token, bật “Refresh token bị đánh cắp” để xem phát hiện dùng lại.The browser only talks to the BFF. The BFF keeps the refresh token in an HttpOnly cookie and forwards sign-in to the auth service or to the OIDC provider. The Order API checks the JWT signature with the public keys (JWKS). Turn on “Access token expired” to see refresh rotation, and “Refresh token stolen” to see reuse detection.
1Người dùng nhập email và mật khẩu trên trang đăng nhập.The user enters an email and password on the sign-in page.
Dùng bcrypt (cost 12) hoặc argon2id (m=64 MB, t=3), mỗi mật khẩu có salt riêng sẵn trong hash. Không dùng SHA-256 thuần vì GPU thử hàng tỷ lần mỗi giây. Lưu chuỗi hash có cả thuật toán và tham số để nâng cost dần khi người dùng đăng nhập lại.Use bcrypt (cost 12) or argon2id (m=64 MB, t=3); each hash carries its own salt. Plain SHA-256 is wrong because a GPU tries billions per second. Store the full hash string with algorithm and parameters so you can raise the cost on the next successful login.
Đếm lần sai trong Redis theo email (5 lần / 15 phút) và theo IP (20 lần / 15 phút). Quá ngưỡng thì trì hoãn tăng dần hoặc đòi CAPTCHA thay vì khoá cứng tài khoản, để kẻ xấu không khoá được người khác. Báo lỗi chung “Email hoặc mật khẩu không đúng” và vẫn chạy bcrypt với hash giả khi email không tồn tại để không lộ qua thời gian phản hồi.Count failures in Redis per email (5 per 15 minutes) and per IP (20 per 15 minutes). Past the limit add a growing delay or a CAPTCHA rather than a hard lock, so attackers cannot lock other people out. Return one generic “wrong email or password” message and still run bcrypt against a dummy hash when the email does not exist, so response time leaks nothing.
JWT hợp lệ tới khi hết exp. Giữ access token ngắn (5–15 phút) và để refresh token, vốn có trạng thái trong DB, làm điểm thu hồi. Trường hợp khẩn cấp thì thêm deny list theo jti trong Redis với TTL bằng thời gian còn lại của token.A JWT stays valid until exp. Keep access tokens short (5–15 minutes) and let the refresh token, which has state in the DB, be the revocation point. For emergencies add a deny list by jti in Redis with a TTL equal to the token's remaining life.
Xoay vòng mỗi lần dùng: token cũ chuyển ROTATED, token mới cùng family. Token ROTATED mà xuất hiện lại thì thu hồi cả family và báo người dùng. Chỉ lưu SHA-256 của token để lộ DB không lộ token.Rotate on every use: the old token becomes ROTATED and the new one joins the same family. If a ROTATED token shows up again, revoke the family and warn the user. Store only the SHA-256 of the token so a leaked DB does not leak tokens.
Không để refresh token trong localStorage. BFF giữ nó trong cookie HttpOnly, Secure, SameSite=Strict, Path=/auth/refresh, kèm CSRF token cho các yêu cầu đổi trạng thái. Access token chỉ ở bộ nhớ của tab.Never put the refresh token in localStorage. The BFF keeps it in an HttpOnly, Secure, SameSite=Strict cookie with Path=/auth/refresh, plus a CSRF token for state-changing requests. The access token lives only in the tab's memory.
Một chuỗi filter: lấy Bearer token → kiểm tra chữ ký với JWKS (cache theo kid) → kiểm tra iss, aud, exp → nạp quyền vào context. Spring Security SecurityFilterChain hay middleware Go đều là Chain of Responsibility; logging và metric bọc ngoài như decorator.A chain of filters: extract the Bearer token → verify the signature against the JWKS (cached by kid) → check iss, aud, exp → put the principal in the context. Spring Security's SecurityFilterChain and Go middleware are both a Chain of Responsibility; logging and metrics wrap it as decorators.
Dùng Authorization Code + PKCE. BFF là confidential client: đổi code lấy token ở phía server, kiểm tra state, nonce và chữ ký id_token, rồi phát phiên riêng của hệ thống. Trình duyệt không bao giờ thấy token của IdP. Mỗi IdP là một strategy cấu hình riêng.Use the Authorization Code flow with PKCE. The BFF is a confidential client: it exchanges the code server-side, checks state, nonce and the id_token signature, then issues the system's own session. The browser never sees the IdP's tokens. Each IdP is a separately configured strategy.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
app_user | id, email, password_hash, password_changed_at, status, mfa_enabled, created_at | unique(lower(email)); password_hash dạng $2b$12$… hoặc $argon2id$v=19$…unique(lower(email)); password_hash looks like $2b$12$… or $argon2id$v=19$… |
refresh_token | id, user_id, token_hash, family_id, parent_id, status, expires_at, created_at, revoked_reason | unique(token_hash); ACTIVE → ROTATED · REVOKED; index(family_id) để thu hồi cả familyunique(token_hash); ACTIVE → ROTATED · REVOKED; index(family_id) to revoke a family |
user_identity | user_id, provider, subject, email, linked_at | PK(provider, subject): liên kết tài khoản Google/Microsoft với user nội bộPK(provider, subject): links a Google/Microsoft account to the local user |
Redis login:fail:{email} · login:fail:ip:{ip} | INCR counter, EXPIRE 900 | Cửa sổ 15 phút; đăng nhập đúng thì DEL bộ đếm theo email15-minute window; a successful login deletes the per-email counter |
signing_key | kid, public_jwk, private_key_ref, status, created_at | Xoay khoá ký mỗi quý; JWKS công bố cả khoá cũ tới khi token cũ hết hạnRotate the signing key every quarter; the JWKS keeps the old key until its tokens expire |
Xoay vòng refresh token có phát hiện dùng lại. Điểm khó: khi phát hiện dùng lại, việc thu hồi phải được commit dù hàm trả lỗi cho client.Refresh token rotation with reuse detection. The tricky part: when reuse is detected, the revocation must commit even though the call returns an error to the client.
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private static final Duration ACCESS_TTL = Duration.ofMinutes(15);
private static final Duration REFRESH_TTL = Duration.ofDays(30);
private final RefreshTokenRepository repo;
private final JwtIssuer jwt;
private final ApplicationEventPublisher events;
private final SecureRandom random = new SecureRandom();
@Transactional(noRollbackFor = InvalidTokenException.class)
public TokenPair rotate(String raw) {
RefreshToken old = repo.findByHashForUpdate(sha256(raw))
.orElseThrow(InvalidTokenException::new);
if (old.getStatus() != Status.ACTIVE) {
repo.revokeFamily(old.getFamilyId(), "reuse");
events.publishEvent(new TokenReuseDetected(old.getUserId(), old.getFamilyId()));
throw new InvalidTokenException();
}
if (old.getExpiresAt().isBefore(Instant.now())) throw new InvalidTokenException();
old.setStatus(Status.ROTATED);
String next = newToken();
repo.save(RefreshToken.childOf(old, sha256(next), Instant.now().plus(REFRESH_TTL)));
return new TokenPair(jwt.issue(old.getUserId(), ACCESS_TTL), next);
}
private String newToken() {
byte[] b = new byte[32];
random.nextBytes(b);
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
}
}type RefreshService struct {
tx TxRunner
jwt JWTIssuer
events Publisher
}
func (s *RefreshService) Rotate(ctx context.Context, raw string) (TokenPair, error) {
var pair TokenPair
var reused *RefreshToken
err := s.tx.Do(ctx, func(tx Tx) error {
old, err := tx.LockRefreshByHash(ctx, sha256Hex(raw)) // FOR UPDATE
if err != nil {
return ErrInvalidToken
}
if old.Status != StatusActive {
reused = old
return tx.RevokeFamily(ctx, old.FamilyID, "reuse") // nil → commit
}
if time.Now().After(old.ExpiresAt) {
return ErrInvalidToken
}
next, err := newToken()
if err != nil {
return err
}
if err := tx.MarkRotated(ctx, old.ID); err != nil {
return err
}
child := old.Child(sha256Hex(next), time.Now().Add(30*24*time.Hour))
if err := tx.InsertRefresh(ctx, child); err != nil {
return err
}
access, err := s.jwt.Issue(old.UserID, 15*time.Minute)
pair = TokenPair{Access: access, Refresh: next}
return err
})
if err != nil {
return TokenPair{}, err
}
if reused != nil {
s.events.Publish(ctx, TokenReuseDetected{UserID: reused.UserID, FamilyID: reused.FamilyID})
return TokenPair{}, ErrInvalidToken
}
return pair, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { // crypto/rand
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}| Tự làm (bcrypt + JWT)Build it (bcrypt + JWT) | IdP qua OIDC (Keycloak, Auth0, Cognito)IdP over OIDC (Keycloak, Auth0, Cognito) | |
|---|---|---|
| Công sứcEffort | Tự viết đăng ký, quên mật khẩu, xoay token, khoá kýYou write sign-up, password reset, token rotation and key management | Cấu hình realm/client, tích hợp OIDC ở BFFConfigure a realm and client, integrate OIDC in the BFF |
| Tính năngFeatures | MFA, social login, SSO phải tự thêm dầnMFA, social login and SSO are added one by one | MFA, social login, SSO, chính sách mật khẩu có sẵnMFA, social login, SSO and password policy out of the box |
| Kiểm soát, chi phíControl, cost | Toàn quyền giao diện và dữ liệu, không phí theo userFull control of UI and data, no per-user fee | Phụ thuộc nhà cung cấp; Auth0/Cognito tính phí theo MAU, Keycloak phải tự vận hànhVendor dependency; Auth0/Cognito charge per MAU, Keycloak must be operated |
| Chọn khiPick it when | Sản phẩm nhỏ, một ứng dụng, đội có kinh nghiệm bảo mậtA small product, one app, a team with security experience | Nhiều ứng dụng cần SSO, khách doanh nghiệp đòi SAML/MFASeveral apps need SSO, enterprise customers require SAML/MFA |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
SHA-256 được thiết kế để nhanh; một GPU thử hàng tỷ ứng viên mỗi giây. bcrypt và argon2id chậm có thể điều chỉnh, argon2id còn tốn bộ nhớ nên GPU/ASIC khó song song hoá. Salt chỉ chặn rainbow table, không làm chậm việc dò từng tài khoản.SHA-256 is built to be fast; one GPU tries billions of candidates a second. bcrypt and argon2id are tunably slow, and argon2id is memory-hard, which hurts GPU and ASIC parallelism. A salt only defeats rainbow tables; it does not slow down guessing one account.
Có, tới khi hết exp, vì API chỉ kiểm chữ ký. Đăng xuất thu hồi refresh token nên phiên không gia hạn được. Muốn chặn ngay thì giữ access token ngắn và thêm deny list theo jti hoặc session id trong Redis.Yes, until exp, because APIs only check the signature. Logout revokes the refresh token so the session cannot be extended. To block it immediately keep access tokens short and add a deny list by jti or session id in Redis.
Mỗi token chỉ dùng một lần; dùng xong chuyển ROTATED. Token ROTATED xuất hiện lại nghĩa là có bản sao, nên thu hồi cả family. Nhược điểm: hai tab refresh cùng lúc, hoặc mạng chập chờn làm mất phản hồi, cũng bị coi là dùng lại. Thường cho một khoảng ân hạn vài giây và khoá dòng để xếp hàng.Each token is single-use and becomes ROTATED after use. A ROTATED token showing up again means a copy exists, so the family is revoked. The downside: two tabs refreshing at once, or a lost response on a flaky network, also look like reuse. A grace window of a few seconds plus a row lock to serialise requests is common.
Mọi thứ JavaScript đọc được thì XSS cũng đọc được. BFF là confidential client, giữ client secret và refresh token ở server hoặc trong cookie HttpOnly, nên token không lộ ra trình duyệt. Đây cũng là khuyến nghị trong OAuth 2.0 for Browser-Based Apps của IETF.Anything JavaScript can read, XSS can read. The BFF is a confidential client that keeps the client secret and refresh token on the server or in an HttpOnly cookie, so tokens never reach the browser. It is also what the IETF's OAuth 2.0 for Browser-Based Apps recommends.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.