이 튜토리얼에서는 웹 앱에 패스키를 구현하는 방법을 설명합니다. Node.js(TypeScript), SimpleWebAuthn, Vanilla HTML/JavaScript, MySQL을 사용합니다.
Vincent
Created: June 20, 2025
Updated: June 20, 2025
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
이 튜토리얼에서는 패스키 구현을 돕기 위해 웹사이트에 패스키를 추가하는 방법에 대한 단계별 가이드를 제공합니다.
훌륭한 웹사이트나 앱을 구축하려면 현대적이고 강력하며 사용자 친화적인 인증이 핵심입니다. 패스키는 이러한 과제에 대한 해답으로 등장했습니다. 새로운 로그인 표준으로서 기존 비밀번호의 단점 없이 진정으로 비밀번호 없는 로그인 경험을 약속하며, 이는 안전할 뿐만 아니라 매우 편리합니다.
패스키의 잠재력을 진정으로 보여주는 것은 패스키가 얻은 지지입니다. Chrome, Firefox, Safari, Edge 등 모든 주요 브라우저와 모든 중요한 기기 제조업체(Apple, Microsoft, Google)가 지원을 통합했습니다. 이러한 만장일치의 지지는 패스키가 새로운 로그인 표준임을 보여줍니다.
물론, 웹 애플리케이션에 패스키를 통합하는 방법에 대한 튜토리얼은 이미 존재합니다. React, Vue.js 또는 Next.js와 같은 프런트엔드 프레임워크를 위한 것이든, 문제를 완화하고 패스키 구현 속도를 높이기 위해 설계된 수많은 가이드가 있습니다. 그러나 **최소한의 베어메탈(bare-metal)**을 유지하는 엔드투엔드(end-2-end) 튜토리얼은 부족합니다. 많은 개발자들이 우리에게 웹 앱을 위한 패스키 구현에 대한 명확성을 제공하는 튜토리얼을 요청해 왔습니다.
이것이 바로 우리가 이 가이드를 만든 이유입니다. 우리의 목표는? 프런트엔드, 백엔드 및 데이터베이스 계층(후자는 종종 심각한 골칫거리를 유발할 수 있음에도 불구하고 무시됨)을 포함하는 패스키를 위한 최소 실행 가능한 설정을 만드는 것입니다.
이 여정이 끝나면 다음과 같은 최소 실행 가능한 웹 애플리케이션을 구축하게 될 것입니다:
급하거나 참고 자료가 필요한 분들을 위해 전체 코드베이스는 GitHub에서 확인할 수 있습니다.
최종 결과가 어떻게 보일지 궁금하신가요? 다음은 최종 프로젝트의 미리보기입니다(매우 기본적으로 보이지만 흥미로운 부분은 그 이면에 있습니다):
우리는 코드와 프로젝트의 일부가 다르게 또는 더 정교하게 만들어질 수 있다는 것을 충분히 알고 있지만, 본질적인 부분에 집중하고 싶었습니다. 그래서 의도적으로 단순하고 패스키 중심적으로 유지했습니다.
프로덕션 웹사이트에 패스키를 추가하는 방법은?
이것은 패스키 인증을 위한 매우 최소한의 예제입니다. 다음 사항은 이 튜토리얼에서 고려되거나 구현되지 않았거나 매우 기본적인 수준으로만 다루어졌습니다:
이 모든 기능을 완벽하게 지원하려면 엄청나게 더 많은 개발 노력이 필요합니다. 관심 있는 분들은 이 패스키 개발자 오해에 관한 기사를 살펴보는 것을 추천합니다.
패스키 구현에 깊이 들어가기 전에 필요한 기술과 도구를 살펴보겠습니다. 시작하기 위해 필요한 것은 다음과 같습니다:
웹의 구성 요소인 HTML, CSS, JavaScript에 대한 확실한 이해가 필수적입니다. 우리는 의도적으로 현대적인 JavaScript 프레임워크를 사용하지 않고 Vanilla JavaScript / HTML에 의존하여 단순하게 유지했습니다. 우리가 사용하는 유일한 더 정교한 것은 WebAuthn 래퍼 라이브러리인 @simplewebauthn/browser입니다.
백엔드에는 TypeScript로 작성된 Node.js (Express) 서버를
사용합니다. 또한 SimpleWebAuthn의 WebAuthn 서버 구현(@simplewebauthn/server
와
@simplewebauthn/typescript-types
함께)을 사용하기로 결정했습니다. 수많은 WebAuthn 서버
구현이 있으므로 물론 이들 중 어떤 것이든 사용할 수 있습니다. TypeScript WebAuthn 서버를
선택했으므로 기본적인 Node.js 및 npm 지식이 필요합니다.
모든 사용자 데이터와 패스키의 공개 키는 데이터베이스에 저장됩니다. 우리는 데이터베이스 기술로 MySQL을 선택했습니다. MySQL 및 관계형 데이터베이스에 대한 기본적인 이해가 도움이 되지만, 단일 단계를 통해 안내해 드릴 것입니다.
이하에서는 공식적으로 같은 의미가 아닐 수 있지만 WebAuthn과 패스키라는 용어를 상호 교환적으로 자주 사용합니다. 특히 코드 부분에서 더 나은 이해를 위해 이러한 가정을 합니다.
이러한 전제 조건이 갖추어지면 패스키의 세계로 뛰어들 준비가 된 것입니다.
Ben Gould
Head of Engineering
I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.
10,000+ devs trust Corbado & make the Internet safer with passkeys. Got questions? We’ve written 150+ blog posts on passkeys.
Join Passkeys Community코드와 구성에 들어가기 전에 우리가 구축하려는 시스템의 아키텍처를 살펴보겠습니다. 다음은 우리가 설정할 아키텍처의 분석입니다:
이 아키텍처 개요를 통해 애플리케이션 구성 요소의 개념적 지도를 갖게 될 것입니다. 진행하면서 각 구성 요소에 대해 더 깊이 파고들어 설정, 구성 및 상호 작용을 자세히 설명할 것입니다.
다음 차트는 등록(가입) 중의 프로세스 흐름을 설명합니다:
다음 차트는 인증(로그인) 중의 프로세스 흐름을 설명합니다:
또한, 여기에 프로젝트 구조가 있습니다(가장 중요한 파일만):
passkeys-tutorial ├── src # 모든 백엔드 TypeScript 소스 코드 포함 │ ├── controllers # 특정 유형의 요청을 처리하기 위한 비즈니스 로직 │ │ ├── authentication.ts # 패스키 인증 로직 │ │ └── registration.ts # 패스키 등록 로직 │ ├── middleware │ │ ├── customError.ts # 표준화된 방식으로 사용자 정의 오류 메시지 추가 │ │ └── errorHandler.ts # 일반 오류 처리기 │ ├── public │ │ ├── index.html # 프런트엔드의 메인 HTML 파일 │ │ ├── css │ │ │ └── style.css # 기본 스타일링 │ │ └── js │ │ └── script.js # JavaScript 로직 (WebAuthn API 포함) │ ├── routes # API 경로 및 핸들러 정의 │ │ └── routes.ts # 특정 패스키 경로 │ ├── services │ │ ├── credentialService.ts# credential 테이블과 상호 작용 │ │ └── userService.ts # user 테이블과 상호 작용 │ ├── utils # 헬퍼 함수 및 유틸리티 │ | ├── constants.ts # 일부 상수 (예: rpID) │ | └── utils.ts # 헬퍼 함수 │ ├── database.ts # Node.js에서 MySQL 데이터베이스로의 연결 생성 │ ├── index.ts # Node.js 서버의 진입점 │ └── server.ts # 모든 서버 설정 관리 ├── config.json # Node.js 프로젝트를 위한 일부 구성 ├── docker-compose.yml # Docker 컨테이너를 위한 서비스, 네트워크, 볼륨 정의 ├── Dockerfile # 프로젝트의 Docker 이미지 생성 ├── init-db.sql # MySQL 데이터베이스 스키마 정의 ├── package.json # Node.js 프로젝트 종속성 및 스크립트 관리 └── tsconfig.json # TypeScript가 코드를 컴파일하는 방법 구성
패스키를 구현할 때 데이터베이스 설정은 핵심 구성 요소입니다. 우리의 접근 방식은 MySQL을 실행하는 Docker 컨테이너를 사용하여 안정적인 테스트 및 배포에 필수적인 간단하고 격리된 환경을 제공합니다.
우리의 데이터베이스 스키마는 의도적으로 최소화되어 두 개의 테이블만 있습니다. 이 단순성은 더 명확한 이해와 쉬운 유지 관리를 돕습니다.
상세 테이블 구조
1. Credentials 테이블: 패스키 인증의 중심인 이 테이블은 패스키 자격 증명을 저장합니다. 중요한 열:
credential_id
와 마찬가지로
적절한 데이터 유형과 형식이 중요합니다.2. Users 테이블: 사용자 계정을 해당 자격 증명에 연결합니다.
첫 번째 테이블을 credentials로 명명한 것은 우리의 경험과 다른 라이브러리가 권장하는 바에 따른 것입니다(SimpleWebAuthn이 authenticator 또는 authenticator_device로 명명하라는 제안과는 반대입니다).
credential_id
와 public_key
의 데이터 유형은 매우 중요합니다. 오류는 종종 잘못된 데이터
유형, 인코딩 또는 형식(특히 Base64와 Base64URL의 차이는 일반적인 오류 원인)에서 발생하며,
이는 전체 등록(가입) 또는 인증(로그인) 프로세스를 방해할 수 있습니다.
이러한 테이블을 설정하는 데 필요한 모든 SQL 명령은 init-db.sql
파일에 포함되어 있습니다.
이 스크립트는 빠르고 오류 없는 데이터베이스 초기화를 보장합니다.
더 정교한 경우, credential_device_type
또는 credential_backed_up
을 추가하여 자격
증명에 대한 더 많은 정보를 저장하고 사용자 경험을 개선할 수 있습니다. 하지만 이
튜토리얼에서는 그렇게 하지 않습니다.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
이 파일을 만든 후, 프로젝트의 루트 레벨에 새로운 docker-compose.yml
파일을 만듭니다:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
이 파일은 포트 3306에서 MySQL 데이터베이스를 시작하고 정의된 데이터베이스 구조를 생성합니다. 여기서 사용된 데이터베이스의 이름과 비밀번호는 데모 목적으로 간단하게 유지되었다는 점에 유의해야 합니다. 프로덕션 환경에서는 보안 강화를 위해 더 복잡한 자격 증명을 사용해야 합니다.
다음으로 Docker 컨테이너를 실행하는 단계로 넘어갑니다. 이 시점에서 docker-compose.yml
파일에는 이 단일 컨테이너만 포함되어 있지만 나중에 더 많은 구성 요소를 추가할 것입니다.
컨테이너를 시작하려면 다음 명령을 사용하십시오:
docker compose up -d
컨테이너가 실행되면 데이터베이스가 예상대로 작동하는지 확인해야 합니다. 터미널을 열고 다음 명령을 실행하여 MySQL 데이터베이스와 상호 작용하십시오:
docker exec -it <container ID> mysql -uroot -p
루트 비밀번호를 입력하라는 메시지가 표시되며, 이 예에서는 my-secret-pw
입니다. 로그인한
후 webauthn_db
데이터베이스를 선택하고 다음 명령을 사용하여 테이블을 표시하십시오:
use webauthn_db; show tables;
이 단계에서 스크립트에 정의된 두 개의 테이블을 볼 수 있습니다. 처음에는 이 테이블들이 비어 있으며, 이는 데이터베이스 설정이 완료되어 패스키 구현의 다음 단계를 위한 준비가 되었음을 나타냅니다.
백엔드는 모든 패스키 애플리케이션의 핵심으로, 프런트엔드로부터의 사용자 인증 요청을 처리하는 중앙 허브 역할을 합니다. 등록(가입) 및 인증(로그인) 요청을 처리하기 위해 WebAuthn 서버 라이브러리와 통신하고, 사용자 자격 증명을 저장하고 검색하기 위해 MySQL 데이터베이스와 상호 작용합니다. 아래에서는 TypeScript를 사용하는 Node.js (Express)를 사용하여 백엔드를 설정하는 방법을 안내하며, 이는 모든 요청을 처리하는 공개 API를 노출합니다.
먼저 프로젝트를 위한 새 디렉토리를 만들고 터미널이나 명령 프롬프트를 사용하여 해당 디렉토리로 이동합니다.
다음 명령을 실행합니다.
npx create-express-typescript-application passkeys-tutorial
이는 우리가 추가적인 수정을 위해 사용할 수 있는 TypeScript로 작성된 Node.js (Express) 앱의 기본 코드 스켈레톤을 생성합니다.
프로젝트에는 추가로 설치해야 하는 몇 가지 주요 패키지가 필요합니다:
새 디렉토리로 전환하고 다음 명령으로 설치합니다(필요한 TypeScript 타입도 설치합니다):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
모든 것이 올바르게 설치되었는지 확인하려면 다음을 실행하십시오.
npm run dev:nodemon
이렇게 하면 개발 모드에서 Nodemon으로 Node.js 서버가 시작되며, 파일 변경 시 서버가 자동으로 다시 시작됩니다.
문제 해결 팁: 오류가 발생하면 package.json
파일에서 ts-node
를 버전 10.8.1로
업데이트한 다음 npm i
를 실행하여 업데이트를 설치해 보십시오.
server.ts
파일에는 Express 애플리케이션을 위한 기본 설정과
미들웨어가 있습니다. 패스키 기능을 통합하려면 다음을 추가해야 합니다:
이러한 개선 사항은 애플리케이션의 백엔드에서 패스키 인증을 활성화하는 데 핵심적입니다. 나중에 설정할 것입니다.
4. MySQL 데이터베이스 설정에서 데이터베이스를 생성하고
시작한 후, 이제 백엔드가 MySQL 데이터베이스에 연결할 수 있는지 확인해야 합니다. 따라서
/src
폴더에 새로운 database.ts
파일을 만들고 다음 내용을 추가합니다:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
이 파일은 나중에 서버가 데이터베이스에 액세스하는 데 사용됩니다.
config.json
을 간단히 살펴보면, 애플리케이션을 실행할 포트와 환경이라는 두 변수가 이미
정의되어 있습니다:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
은 그대로 유지할 수 있으며 다음과 같아야 합니다:
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
는 다음과 같습니다:
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
server.ts
에서는 몇 가지를 더 수정해야 합니다. 또한, 사용자가 인증할 수 있는 임시
챌린지를 저장하기 위해 일종의 임시 캐시(예: redis, memcache 또는
express-session)가 필요합니다. 우리는 express-session
을
사용하기로 결정하고 express-session
이 작동하도록 상단에 express-session
모듈을
선언합니다. 또한 라우팅을 간소화하고 지금은 오류 처리를 제거합니다(이는 나중에 미들웨어에
추가될 것입니다):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
생성된 두 테이블의 데이터를 효과적으로 관리하기 위해 새로운 src/services
디렉토리에
authenticatorService.ts
와 userService.ts
라는 두 개의 개별 서비스를 개발할 것입니다.
각 서비스는 CRUD(생성, 읽기, 업데이트, 삭제) 메서드를 캡슐화하여 모듈식이고 조직적인 방식으로 데이터베이스와 상호 작용할 수 있게 합니다. 이러한 서비스는 authenticator 및 사용자 테이블의 데이터를 저장, 검색 및 업데이트하는 것을 용이하게 합니다. 필요한 파일의 구조는 다음과 같아야 합니다:
userService.ts
는 다음과 같습니다:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
는 다음과 같습니다:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
오류를 중앙에서 처리하고 디버깅을 더 쉽게 하기 위해 errorHandler.ts
파일을 추가합니다:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
또한, 나중에 버그를 더 빨리 찾을 수 있도록 사용자 정의 오류를 생성할 수 있도록 새로운
customError.ts
파일을 추가합니다:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
utils
폴더에 constants.ts
와 utils.ts
두 개의 파일을 만듭니다.
constant.ts
는 신뢰 당사자(relying party) 이름, 신뢰 당사자 ID(relying party ID) 및
오리진(origin)과 같은 일부 기본 WebAuthn 서버 정보를 보유합니다:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
는 나중에 데이터 인코딩 및 디코딩에 필요한 두 개의 함수를 보유합니다:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
이제 백엔드의 핵심인 컨트롤러에 도달했습니다. 새 패스키를 생성하기 위한
컨트롤러(registration.ts
)와 패스키로 로그인하기 위한 컨트롤러(authentication.ts
) 두
개를 만듭니다.
registration.ts
는 다음과 같습니다:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
WebAuthn 등록(가입) 프로세스에서 두 개의 주요 엔드포인트를 처리하는 컨트롤러의 기능을 검토해 보겠습니다. 이는 또한 비밀번호 기반 인증과의 가장 큰 차이점 중 하나입니다: 모든 등록(가입) 또는 인증(로그인) 시도에는 두 번의 백엔드 API 호출이 필요하며, 이 사이에는 특정 프런트엔드 콘텐츠가 필요합니다. 비밀번호는 보통 하나의 엔드포인트만 필요합니다.
1. handleRegisterStart 엔드포인트:
이 엔드포인트는 프런트엔드에서 트리거되며, 새 패스키와 계정을 생성하기 위해 사용자 이름을 받습니다. 이 예에서는 아직 계정이 없는 경우에만 새 계정/패스키 생성을 허용합니다. 실제 애플리케이션에서는 패스키가 이미 존재하며 동일한 기기에서 추가할 수 없다고 사용자에게 알리는 방식으로 처리해야 합니다(하지만 사용자는 어떤 형태의 확인 후 다른 기기에서 패스키를 추가할 수 있습니다). 단순화를 위해 이 튜토리얼에서는 이를 간과합니다.
PublicKeyCredentialCreationOptions가
준비됩니다. residentKey
는 preferred로, attestationType
은 direct로 설정되어, 잠재적인
데이터베이스 저장을 위해 인증자(authenticator)로부터 더 많은 데이터를 수집합니다.
일반적으로 PublicKeyCredentialCreationOptions는 다음 데이터로 구성됩니다:
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
)과 도메인(rp.id
)을 포함합니다.user.name
, user.id
, user.displayName
과 같은 사용자 계정 세부 정보를
포함합니다.사용자 ID와 챌린지는 세션 객체에 저장되어 튜토리얼 목적상 프로세스를 단순화합니다. 또한, 각 등록(가입) 또는 인증(로그인) 시도 후 세션이 지워집니다.
2. handleRegisterFinish 엔드포인트:
이 엔드포인트는 이전에 설정된 사용자 ID와 챌린지를 검색합니다. RegistrationResponse
를
챌린지로 확인합니다. 유효한 경우 사용자에 대한 새 자격 증명을 저장합니다. 데이터베이스에
저장되면 사용자 ID와 챌린지가 세션에서 제거됩니다.
팁: 애플리케이션을 디버깅할 때, Chrome 브라우저와 내장된 기능을 사용하여 패스키 기반 애플리케이션의 개발자 경험을 향상시키는 것을 강력히 권장합니다. 예를 들어, 가상 WebAuthn 인증자 및 기기 로그가 있습니다(자세한 내용은 아래의 개발자를 위한 추가 패스키 팁 참조).
다음으로, 비슷한 구조와 기능을 가진 authentication.ts
로 이동합니다.
authentication.ts
는 다음과 같습니다:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
우리의 인증(로그인) 프로세스는 두 개의 엔드포인트를 포함합니다:
1. handleLoginStart 엔드포인트:
이 엔드포인트는 사용자가 로그인을 시도할 때 활성화됩니다. 먼저 데이터베이스에서 사용자 이름이 존재하는지 확인하고, 찾을 수 없으면 오류를 반환합니다. 실제 시나리오에서는 대신 새 계정을 생성하도록 제안할 수 있습니다.
기존 사용자의 경우 데이터베이스에서 사용자 ID를 검색하여 세션에 저장하고 PublicKeyCredentialRequestOptions 옵션을 생성합니다. allowCredentials는 자격 증명 사용을 제한하지 않기 위해 비워 둡니다. 그래서 이 신뢰 당사자(relying party)에 대해 사용 가능한 모든 패스키가 패스키 모달에서 선택될 수 있습니다.
생성된 챌린지도 세션에 저장되고 PublicKeyCredentialRequestOptions는 프런트엔드로 다시 전송됩니다.
PublicKeyCredentialRequestOptions는 다음 데이터로 구성됩니다:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. handleLoginFinish 엔드포인트:
이 엔드포인트는 세션에서 currentChallenge
와 loggedInUserId
를 검색합니다.
본문에서 온 자격 증명 ID를 사용하여 데이터베이스에서 올바른 자격 증명을 쿼리합니다. 자격
증명이 발견되면, 이 자격 증명 ID와 연관된 사용자가 이제 인증(로그인)될 수 있음을
의미합니다. 그런 다음, 자격 증명에서 얻은 사용자 ID를 통해 사용자 테이블에서 사용자를
쿼리하고 챌린지와 요청 본문을 사용하여 authenticationResponse
를 확인할 수 있습니다. 모든
것이 성공적이면 로그인 성공 메시지를 표시합니다. 일치하는 자격 증명이 없으면 오류가
전송됩니다.
또한, 확인이 성공하면 자격 증명의 카운터가 업데이트되고, 사용된 챌린지와 loggedInUserId가 세션에서 제거됩니다.
그 위에, src/app
및 src/constant
폴더와 그 안의 모든 파일을 삭제할 수 있습니다.
참고: 실제 애플리케이션에서 중요한 적절한 세션 관리 및 경로 보호는 이 튜토리얼의 단순성을 위해 생략되었습니다.
마지막으로, 새로운 src/routes
디렉토리에 있는 routes.ts
에 적절한 경로를 추가하여
컨트롤러에 접근할 수 있도록 해야 합니다:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
이 패스키 튜토리얼의 이 부분은 애플리케이션의 프런트엔드에서 패스키를 지원하는 방법에
중점을 둡니다. 우리는 index.html
, styles.css
, script.js
세 개의 파일로 구성된 매우
기본적인 프런트엔드를 가지고 있습니다. 세 파일 모두 새로운 src/public
폴더에 있습니다.
index.html
파일에는 사용자 이름을 위한 입력 필드와 등록 및 로그인을 위한 두 개의 버튼이
포함되어 있습니다. 또한, js/script.js
파일에서 브라우저 Web Authentication API와의 상호
작용을 단순화하는 @simplewebauthn/browser
스크립트를 가져옵니다.
index.html
은 다음과 같습니다:
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
는 다음과 같습니다:
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
script.js
에는 세 가지 주요 기능이 있습니다:
1. showMessage 함수:
이것은 주로 오류 메시지를 표시하는 데 사용되는 유틸리티 함수로, 디버깅에 도움이 됩니다.
2. Register 함수:
사용자가 "Register"를 클릭하면 트리거됩니다. 입력 필드에서 사용자 이름을 추출하여
passkeyRegisterStart 엔드포인트로 보냅니다. 응답에는
PublicKeyCredentialCreationOptions가 포함되며, 이는 JSON으로 변환되어
SimpleWebAuthnBrowser.startRegistration
에 전달됩니다. 이 호출은 기기 인증자(예: Face ID
또는 Touch ID)를 활성화합니다. 로컬 인증에 성공하면 서명된 챌린지가
passkeyRegisterFinish
엔드포인트로 다시 전송되어 패스키 생성 프로세스를 완료합니다.
등록(가입) 과정에서 증명(attestation) 객체는 중요한 역할을 하므로 자세히 살펴보겠습니다.
증명(attestation) 객체는 주로 fmt
, attStmt
, authData
세 가지 구성 요소로
이루어집니다. fmt
요소는 증명(attestation) 문의 형식을 나타내고, attStmt
는 실제 증명
문 자체를 나타냅니다. 증명이 불필요하다고 간주되는 시나리오에서는 fmt
가 "none"으로
지정되어 attStmt
가 비어 있게 됩니다.
이 구조 내에서 authData
세그먼트에 초점이 맞춰집니다. 이 세그먼트는 우리 서버에서 신뢰
당사자 ID, 플래그, 카운터 및 증명된 자격 증명 데이터와 같은 필수 요소를 검색하는 데
핵심입니다. 플래그와 관련하여 특히 흥미로운 것은 BS(백업 상태)와 BE(백업 자격)로, 패스키가
동기화되었는지(예: iCloud 키체인 또는
1Password를 통해)에 대한 더 많은
정보를 제공합니다. 또한 UV(사용자 확인) 및 UP(사용자 존재)는 더 유용한 정보를 제공합니다.
인증자 데이터, 신뢰 당사자 ID, 증명 문을 포함한 증명 객체의 다양한 부분이 인증자의 개인 키를 사용하여 해시되거나 디지털 서명된다는 점에 유의하는 것이 중요합니다. 이 프로세스는 증명 객체의 전반적인 무결성을 유지하는 데 필수적입니다.
3. Login 함수:
사용자가 "Login"을 클릭하면 활성화됩니다. 등록 함수와 유사하게 사용자 이름을 추출하여
passkeyLoginStart
엔드포인트로 보냅니다. PublicKeyCredentialRequestOptions를
포함하는 응답은 JSON으로 변환되어 SimpleWebAuthnBrowser.startAuthentication
과 함께
사용됩니다. 이는 기기에서 로컬 인증을 트리거합니다. 서명된 챌린지는 passkeyLoginFinish
엔드포인트로 다시 전송됩니다. 이 엔드포인트로부터의 성공적인 응답은 사용자가 앱에
성공적으로 로그인했음을 나타냅니다.
또한, 함께 제공되는 CSS 파일은 애플리케이션에 간단한 스타일을 제공합니다:
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
애플리케이션이 작동하는 것을 보려면 TypeScript 코드를 컴파일하고 실행하십시오:
npm run dev
이제 서버가 http://localhost:8080에서 실행 중이어야 합니다.
프로덕션 고려 사항:
우리가 다룬 것은 기본적인 개요라는 것을 기억하십시오. 프로덕션 환경에 패스키 애플리케이션을 배포할 때는 다음을 더 깊이 파고들어야 합니다:
우리는 이미 데이터베이스용 Docker 컨테이너를 설정했습니다. 다음으로, 백엔드와 프런트엔드를
모두 포함하는 서버를 포함하도록 Docker Compose 설정을 확장할 것입니다.
docker-compose.yml
파일은 그에 따라 업데이트되어야 합니다.
애플리케이션을 컨테이너화하기 위해 필요한 패키지를 설치하고 개발 서버를 시작하는 새로운 Dockerfile을 만듭니다:
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
그런 다음, 이 컨테이너를 시작하도록 docker-compose.yml
파일도 확장합니다:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
이제 터미널에서 docker compose up
을 실행하고
http://localhost:8080에 접속하면 패스키 웹 앱의 작동 버전을 볼 수
있습니다(여기서는 Windows 11 23H2 + Chrome 119에서 실행 중):
우리는 꽤 오랫동안 패스키 구현 작업을 해왔기 때문에 실제 패스키 앱에서 작업할 때 몇 가지 문제에 직면했습니다:
또한, 구현 부분에 관해서는 개발자를 위한 다음과 같은 팁이 있습니다:
Passkeys Debugger 활용
Passkeys 디버거는 다양한 WebAuthn 서버 설정과 클라이언트 응답을 테스트하는 데 도움이 됩니다. 또한, 인증자 응답에 대한 훌륭한 파서를 제공합니다.
Chrome 기기 로그 기능으로 디버깅
Chrome의 기기 로그(chrome://device-log/를 통해 액세스 가능)를 사용하여 FIDO/WebAuthn 호출을 모니터링하십시오. 이 기능은 인증(로그인) 프로세스의 실시간 로그를 제공하여 교환되는 데이터를 보고 발생하는 모든 문제를 해결할 수 있게 해줍니다.
Chrome에서 모든 패스키를 가져오는 또 다른 매우 유용한 바로 가기는 chrome://settings/passkeys를 사용하는 것입니다.
Chrome 가상 WebAuthn 인증자 사용
개발 중에 Touch ID, Face ID 또는 Windows Hello 프롬프트를 사용하지 않으려면 Chrome에는 실제 인증자를 에뮬레이트하는 매우 편리한 가상 WebAuthn 인증자가 함께 제공됩니다. 작업 속도를 높이기 위해 사용하는 것을 강력히 권장합니다. 자세한 내용은 여기에서 확인하십시오.
다양한 플랫폼 및 브라우저에서 테스트
다양한 브라우저 및 플랫폼에서 호환성과 기능을 보장하십시오. WebAuthn은 다른 브라우저에서 다르게 작동하므로 철저한 테스트가 핵심입니다.
다른 기기에서 테스트
여기서는 특히 ngrok과 같은 도구를 사용하여 로컬 애플리케이션을 다른 (모바일) 기기에서 접근할 수 있도록 하는 것이 유용합니다.
사용자 확인을 Preferred로 설정
PublicKeyCredentialRequestOptions에서 userVerification 속성을 정의할 때, 사용성과 보안 사이의 좋은 절충안이므로 preferred로 설정하는 것을 선택하십시오. 이는 적합한 기기에서는 보안 검사가 적용되면서 생체 인식 기능이 없는 기기에서는 사용자 친화성이 유지됨을 의미합니다.
이 패스키 튜토리얼이 패스키를 효과적으로 구현하는 방법에 대한 명확한 이해를 제공하기를 바랍니다. 튜토리얼 전반에 걸쳐 기본 개념과 실제 구현에 중점을 두고 패스키 애플리케이션을 만드는 필수 단계를 안내했습니다. 이 가이드가 시작점 역할을 하지만, WebAuthn의 세계에는 탐색하고 개선할 것이 훨씬 더 많습니다.
개발자들이 패스키의 미묘한 차이(예: 여러 패스키 추가, 기기에서 패스키 준비 상태 확인 또는 복구 솔루션 제공)에 더 깊이 파고들 것을 권장합니다. 이는 사용자 인증을 향상시키는 데 있어 도전과 엄청난 보상을 모두 제공하는 가치 있는 여정입니다. 패스키를 사용하면 단순히 기능을 구축하는 것이 아니라 더 안전하고 사용자 친화적인 디지털 세계에 기여하는 것입니다.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents