Passkey thay mật khẩu: cách hoạt động và cách tích hợp

1. Tổng quan

Mật khẩu có ba vấn đề lớn: dễ đoán, bị dùng lại ở nhiều nơi, và dễ bị lừa nhập vào trang giả. Passkey giải quyết cả ba bằng mật mã khóa công khai. Server không còn giữ bí mật nào của người dùng.

Bài này giải thích passkey là gì và luồng đăng ký, đăng nhập diễn ra thế nào. Sau đó bạn sẽ bật passkey cho một ứng dụng Spring Boot 3.x bằng Spring Security. Cuối bài là các lỗi hay gặp và những trường hợp chưa nên dùng passkey.

2. Passkey là gì?

Passkey là một WebAuthn credential, tức một credential theo chuẩn FIDO2. Về bản chất, đó là một cặp khóa:

  • Private key nằm trên thiết bị của người dùng (điện thoại, laptop, khóa bảo mật USB) hoặc trong trình quản lý mật khẩu. Khóa này không bao giờ rời khỏi đó.
  • Public key được gửi lên server khi đăng ký. Server lưu khóa này cùng với tài khoản.

Mỗi cặp khóa gắn với một Relying Party (RP), tức website hoặc ứng dụng của bạn, thông qua RP ID. RP ID thường là tên miền, ví dụ shop.example.com.

Có hai loại passkey chính:

Loại Đặc điểm
Synced passkey Được đồng bộ qua tài khoản đám mây của hệ điều hành hoặc trình quản lý mật khẩu. Mất máy vẫn khôi phục được.
Device-bound passkey Chỉ tồn tại trên một thiết bị, ví dụ khóa bảo mật phần cứng. An toàn hơn nhưng mất thiết bị là mất khóa.

Người dùng mở khóa passkey bằng vân tay, khuôn mặt hoặc mã PIN của thiết bị. Dữ liệu sinh trắc học chỉ dùng để mở khóa cục bộ. Nó không được gửi lên server.

3. Cách passkey hoạt động

3.1. Đăng ký (registration)

  1. Server tạo một challenge ngẫu nhiên. Server gửi challenge kèm thông tin RP và user về trình duyệt.
  2. Trình duyệt gọi navigator.credentials.create(). Authenticator sinh cặp khóa mới cho đúng RP ID đó.
  3. Trình duyệt gửi public key, credential ID và dữ liệu đã ký về server.
  4. Server kiểm tra challenge và origin, rồi lưu public key.

3.2. Đăng nhập (authentication)

  1. Server tạo challenge mới.
  2. Trình duyệt gọi navigator.credentials.get(). Authenticator ký challenge bằng private key.
  3. Server dùng public key đã lưu để xác minh chữ ký.

Phần cốt lõi là “ký bằng private key, xác minh bằng public key”. Đoạn Java thuần dưới đây mô phỏng đúng bước này:

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // Thiết bị sinh cặp khóa khi đăng ký (EC P-256, tương ứng ES256 phổ biến trong WebAuthn)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // Server tạo challenge ngẫu nhiên cho lần đăng nhập
        byte[] challenge = randomBytes();

        // Thiết bị ký challenge bằng private key
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // Server xác minh bằng public key đã lưu
        System.out.println("Challenge đúng: " + verify(storedOnServer, challenge, signature));
        System.out.println("Challenge khác: " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

Kết quả:

Challenge đúng: true
Challenge khác: false

Từ kết quả này, bạn rút ra được hai điều:

  • Server chỉ giữ storedOnServer, tức public key. Kẻ tấn công lấy được database cũng không dùng được để đăng nhập.
  • Chữ ký chỉ đúng với challenge của lần đó. Nếu bắt được chữ ký cũ và gửi lại (replay attack), kẻ tấn công cũng không vượt qua được bước xác minh.

Trong WebAuthn thật, dữ liệu được ký còn chứa origin của trang. Trình duyệt tự điền origin, trang web không giả mạo được. Vì vậy nếu người dùng vào nhầm examp1e.com, trình duyệt sẽ không dùng passkey của example.com. Phishing bị chặn ngay ở tầng giao thức, không phụ thuộc vào sự cẩn thận của người dùng.

4. Vì sao passkey đang được quan tâm

  • Chống phishing từ thiết kế. Mật khẩu và OTP đều có thể bị lừa nhập vào trang giả. Passkey thì không.
  • Không có bí mật trên server. Rò rỉ database không làm lộ thông tin đăng nhập.
  • Trải nghiệm tốt hơn. Người dùng chỉ cần chạm vân tay, không phải nhớ mật khẩu hay chờ SMS.
  • Hệ sinh thái đã sẵn sàng. Các hệ điều hành, trình duyệt và trình quản lý mật khẩu phổ biến đều hỗ trợ đồng bộ passkey. Nhờ vậy, việc mất thiết bị không còn là rào cản lớn như trước.
  • Framework hỗ trợ sẵn. Bạn không phải tự viết phần xác minh mật mã, vốn rất dễ sai.

5. Tích hợp passkey với Spring Security

Từ Spring Security 6.4 (đi kèm Spring Boot 3.4 trở lên), framework có sẵn DSL webAuthn(). Bên dưới, phần xác minh dùng thư viện WebAuthn4J.

5.1. Dependency

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

Bạn khai báo property webauthn4j.version với phiên bản tương thích với Spring Security đang dùng. Tài liệu Spring Security có ghi rõ phiên bản được kiểm thử.

5.2. Cấu hình SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

Ý nghĩa từng phần:

  • rpName: tên hiển thị trong hộp thoại passkey của hệ điều hành.
  • rpId: tên miền gắn với passkey. Giá trị này không có scheme hay port. Trên production, đó sẽ là shop.example.com.
  • allowedOrigins: các origin hợp lệ. Origin phải khớp chính xác cả scheme và port.
  • formLogin: vẫn giữ lại. Người dùng cần đăng nhập bằng mật khẩu lần đầu để đăng ký passkey. Passkey là credential gắn thêm vào tài khoản đã có.

Khi chạy ứng dụng, Spring Security tự tạo các endpoint sau:

GET  /webauthn/register              trang quản lý/đăng ký passkey (mặc định)
POST /webauthn/register/options      tạo challenge đăng ký
POST /webauthn/register              lưu public key mới
POST /webauthn/authenticate/options  tạo challenge đăng nhập
POST /login/webauthn                 xác minh chữ ký và đăng nhập

Trang login mặc định sẽ có thêm nút “Sign in with a passkey”. Luồng thử nghiệm gồm bốn bước:

  1. Đăng nhập bằng minh / matkhau-tam.
  2. Mở /webauthn/register và tạo passkey.
  3. Đăng xuất.
  4. Đăng nhập lại bằng passkey.

5.3. Lưu trữ credential

Mặc định, Spring Security lưu credential trong bộ nhớ. Restart ứng dụng là mất hết passkey. Trên production, bạn cần cung cấp bean triển khai hai interface:

  • UserCredentialRepository: lưu public key, credential ID và signature count.
  • PublicKeyCredentialUserEntityRepository: ánh xạ user của bạn với user handle của WebAuthn.

Các bản Spring Security mới có sẵn triển khai JDBC cho hai interface này. Bạn cũng có thể tự viết bằng JPA. Khi đó, mỗi credential nên là một bản ghi riêng, vì một người dùng thường có nhiều passkey trên nhiều thiết bị.

6. Phía trình duyệt

Nếu dùng trang login mặc định, Spring Security đã xử lý JavaScript giúp bạn. Với giao diện tự làm, luồng đăng nhập trông như sau:

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // 1. Xin challenge từ server
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // 2. Trình duyệt hiện hộp thoại, authenticator ký challenge
  const credential = await navigator.credentials.get({ publicKey: options });

  // 3. Gửi chữ ký về server để xác minh
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

Các bước trong đoạn code:

  • Bước 1 lấy challenge. Request phải kèm CSRF token vì đây là POST.
  • parseRequestOptionsFromJSON chuyển các trường base64url thành ArrayBuffer. Trình duyệt cũ có thể chưa hỗ trợ hàm này. Khi đó bạn cần một thư viện helper hoặc tự decode.
  • Bước 3 gửi kết quả về server. Định dạng JSON phải khớp với định dạng server mong đợi. Script trong trang login mặc định của Spring Security là tài liệu tham khảo tốt nhất để đối chiếu.

7. Kiểm thử

Bạn không thể giả lập authenticator thật trong unit test một cách đơn giản. Tuy vậy, bạn vẫn kiểm tra được phần quan trọng phía server: endpoint tạo challenge hoạt động và mỗi lần gọi trả về một challenge khác nhau.

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester (Spring Framework 6.2+) cho phép viết assertion theo kiểu AssertJ. csrf() lấy từ SecurityMockMvcRequestPostProcessors. Test thứ nhất xác nhận challenge không bị dùng lại, đây chính là cơ chế chống replay. Test thứ hai đảm bảo rpId đúng cấu hình. Đây là lỗi cấu hình hay gặp nhất khi deploy.

Để kiểm thử end-to-end, bạn dùng virtual authenticator của trình duyệt. Chrome DevTools có tab WebAuthn, và WebDriver/Playwright cũng hỗ trợ tính năng này.

8. Lỗi hay gặp và khi nào không nên dùng

8.1. Lỗi hay gặp

  • Sai rpId hoặc allowedOrigins. Ghi https:// vào rpId, hoặc quên port trong origin, sẽ khiến trình duyệt hoặc server từ chối. Bạn nên tách hai giá trị này ra application.yaml theo từng môi trường.
  • Không dùng HTTPS. WebAuthn chỉ chạy trong secure context. localhost là ngoại lệ duy nhất.
  • Đổi tên miền. Passkey gắn với RP ID. Nếu đổi domain chính, toàn bộ passkey cũ sẽ không dùng được nữa.
  • Lưu trữ in-memory trên production. Restart là người dùng mất passkey.
  • Chỉ cho đăng ký một passkey. Người dùng cần nhiều passkey cho nhiều thiết bị. Hãy có trang liệt kê và cho phép xóa từng passkey.

Lưu ý: Passkey chỉ mạnh khi luồng khôi phục tài khoản cũng mạnh. Nếu “quên passkey” chỉ cần gửi link qua email, kẻ tấn công sẽ nhắm vào email thay vì passkey.

8.2. Khi nào chưa nên dùng (hoặc không nên dùng một mình)

  • Người dùng trên thiết bị cũ hoặc thiết bị dùng chung, như máy tính ở quầy hay kiosk. Lúc này bạn cần giữ phương thức dự phòng.
  • Hệ thống không dựa trên trình duyệt hay app, ví dụ API giữa các service, CLI hoặc job chạy nền. Những trường hợp này nên dùng OAuth2 client credentials, mTLS hoặc API key.
  • Yêu cầu tuân thủ chỉ chấp nhận khóa phần cứng. Synced passkey có thể không đạt yêu cầu. Bạn cần device-bound passkey và kiểm tra attestation.
  • Đội ngũ chưa có quy trình hỗ trợ khôi phục tài khoản. Bạn nên triển khai passkey như một lựa chọn bổ sung trước, rồi mới tiến tới bỏ mật khẩu.

9. Kết luận

Passkey thay bí mật dùng chung (mật khẩu) bằng cặp khóa công khai gắn với tên miền. Nhờ vậy, passkey loại bỏ phishing, credential stuffing và rủi ro rò rỉ database chỉ bằng một cơ chế. Với Spring Security, bạn chỉ cần thêm webAuthn() vào SecurityFilterChain, cấu hình đúng rpId và allowedOrigins, rồi thay kho lưu credential in-memory bằng kho bền vững.

Cách triển khai an toàn nhất là đi từng bước: cho người dùng thêm passkey vào tài khoản hiện có, thiết kế luồng khôi phục chặt chẽ, rồi mới cân nhắc tắt đăng nhập bằng mật khẩu.

passkeywebauthnspring-securitybao-mattech-trends