{"id":21517828,"url":"https://github.com/brokenhandsio/swift-webauthn-guide","last_synced_at":"2025-06-25T23:04:56.673Z","repository":{"id":211016711,"uuid":"719808750","full_name":"brokenhandsio/swift-webauthn-guide","owner":"brokenhandsio","description":null,"archived":false,"fork":false,"pushed_at":"2023-12-06T01:09:02.000Z","size":43,"stargazers_count":7,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-06-25T23:04:56.196Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/brokenhandsio.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null}},"created_at":"2023-11-17T00:07:17.000Z","updated_at":"2025-01-06T15:39:04.000Z","dependencies_parsed_at":"2023-12-06T03:24:13.827Z","dependency_job_id":"7814f907-89e1-4d3c-81a7-0ffa5f3708fc","html_url":"https://github.com/brokenhandsio/swift-webauthn-guide","commit_stats":null,"previous_names":["brokenhandsio/swift-webauthn-guide"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/brokenhandsio/swift-webauthn-guide","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brokenhandsio%2Fswift-webauthn-guide","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brokenhandsio%2Fswift-webauthn-guide/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brokenhandsio%2Fswift-webauthn-guide/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brokenhandsio%2Fswift-webauthn-guide/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/brokenhandsio","download_url":"https://codeload.github.com/brokenhandsio/swift-webauthn-guide/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brokenhandsio%2Fswift-webauthn-guide/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":261967132,"owners_count":23237663,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-24T00:45:30.129Z","updated_at":"2025-06-25T23:04:56.651Z","avatar_url":"https://github.com/brokenhandsio.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Going passwordless with Vapor and Passkeys\n\n### Introduction Structure:\n\nIn this tutorial we will explore Passkeys. To be more specific, we'll explore how we can integrate the Swift WebAuthn library into a server-side Swift app. The process of registering and authenticating using Passkeys is pretty simple, but requires some back and forth between client and server. Therefore this tutorial is split into two separate parts:\n\n1. [Passkey Registration]()\n2. [Passkey Authentication]()\n\nTo avoid starting completely from scratch and turning this blog article into a whole book, I prepared a small starter project which you can [download here](https://github.com/brokenhandsio/swift-webauthn-guide).\n\nToday I'll show you an example implementation for a standalone Passkey login, however it is also possible to integrate webauthn-swift along an existing, password-based, login, for hardware based 2FA.\n\nWhat are Passkeys? Others already did a good job at explaining this, so why reinvent the wheel? Here is a quote from [passkeys.com](https://passkeys.com):\n\n\u003e Passkeys are the new standard to authenticate on the web.\n\u003e Passkeys are a safer and easier replacement for passwords. With passkeys, users can sign in to apps and websites with a biometric sensor (such as a fingerprint or facial recognition), PIN, or pattern, freeing them from having to remember and manage passwords.\n\nTo read more about Passkeys and how they work I recommend the following two resources:\n\n- Introduction: https://webauthn.guide/\n- Details: https://w3c.github.io/webauthn/\n- Apple Developer Documentation: https://developer.apple.com/passkeys/\n\n## Act 1 - Setup\n\n#### Setting up the frontend\n\nPasskeys are integrated into our browsers. Through a JavaScript api exposed by the browsers we trigger the Passkey prompts.\n\n*Safari Passkey prompt:*\n\u003cimg width=\"1728\" alt=\"passkey_prompt_2\" src=\"https://github.com/brokenhandsio/swift-webauthn-guide/assets/44228394/e3d8bb98-2336-450c-b845-d94623dbf0a5\"\u003e\n\n\n*Another example - 1Password prompt:*\n\u003cimg width=\"1728\" alt=\"passkey_prompt_1\" src=\"https://github.com/brokenhandsio/swift-webauthn-guide/assets/44228394/9d155c1a-bd95-42a6-ab86-233538065078\"\u003e\n\n\nThese two prompts are the result of calling `navigator.credentials.create(...)` and `navigator.credentials.get(...)`.\n\nTo get a better understanding let's quickly play around with this API. Go to `https://swift.org`, open the developer panel of your browser and switch to the JavaScript console. Create the following variable:\n\n```JavaScript\nconst publicKeyCredentialCreationOptions = {\n    challenge: Uint8Array.from(\n        \"randomStringFromServer\", c =\u003e c.charCodeAt(0)),\n    rp: {\n        name: \"Swift\",\n        id: \"swift.org\",\n    },\n    user: {\n        id: Uint8Array.from(\n            \"UZSL85T9AFC\", c =\u003e c.charCodeAt(0)),\n        name: \"me@example.com\",\n        displayName: \"FooBar\",\n    },\n    pubKeyCredParams: [{alg: -7, type: \"public-key\"}],\n    authenticatorSelection: {\n        authenticatorAttachment: \"cross-platform\",\n    },\n    timeout: 60000,\n    attestation: \"direct\"\n};\n```\n\nDon't worry, you don't have to understand it's content. In fact the Swift WebAuthn library will create this for you automatically. Now calling the Passkeys API with our newly created `publicKeyCredentialCreationOptions` will prompt you to create a new Passkey:\n\n```JavaScript\nconst credential = await navigator.credentials.create({\n    publicKey: publicKeyCredentialCreationOptions\n});\n```\n\n#### Setting up the Relying Party\n\nIf you haven't already downloaded the [demo project](https://github.com/brokenhandsio/swift-webauthn-guide), you should do so now. There's a `starter` and `final` project. Open the starter project and add the Swift WebAuthn library to your `Package.swift`:\n\n```Swift\ndependencies: [\n    // ...\n    .package(url: \"https://github.com/swift-server/webauthn-swift.git\", from: \"1.0.0-alpha\")\n],\n\n// ...\n\ntargets: [\n    .target(\n        name: \"App\",\n        dependencies: [\n            // ...\n            .product(name: \"WebAuthn\", package: \"webauthn-swift\")\n// ...\n]\n```\n\nFirst, you need to create an instance of `WebAuthnManager`, the core of the Swift WebAuthn library. The WebAuthn library works with any server-side Swift framework, but we'll use Vapor for this tutorial. With Vapor, you could extend `Request` with a `webAuthn` property which allows us to easily access it in the route handlers. Add this in a new file called `Request+webAuthn.swift`:\n\n```swift\nimport Vapor\nimport WebAuthn\n\nextension Request {\n    var webAuthn: WebAuthnManager {\n        WebAuthnManager(\n            config: WebAuthnManager.Config(\n                // 1\n                relyingPartyID: \"localhost\",\n                // 2\n                relyingPartyName: \"Vapor Passkey Tutorial\",\n                // 3\n                relyingPartyOrigin: \"http://localhost:8080\"\n            )\n        )\n    }\n}\n```\n\nHere we configure 3 things:\n1. The `relyingPartyID` identifies your app based solely on the domain (not the scheme, port, or path) it can be accessed on. All created Passkeys will be scoped to this identifier. That means a Passkey created at `example.org` can only be used on the same domain. This prevents other websites from talking to random Passkeys. However this also means if you want to change your domain at some point all users need to re-create their Passkeys!\n2. The `relyingPartyName` is just a friendly name shown to the user when registering or logging in.\n3. The `relyingPartyOrigin` works similar to the relying party id, but [serves as an additional layer of protection](https://w3c.github.io/webauthn/#sctn-validating-origin). Here we need to specify the whole origin. In our case it's the scheme `https://` + the relying party id + the port `:8080`\n\n🚨 It is important that you run your app on `localhost` and not on `127.0.0.1` since _some_ WebAuthn browser implementations, password managers and authenticators only work with \"valid\" domains. With Vapor you can achieve this by using `--hostname localhost`:\n```bash\nswift run App serve --hostname localhost\n```\n\nGreat, that's everything we need to get started.\n\n\n## Act 2 - Registration\n\nFrom the UI perspective we only need three components: Two buttons and a text field for entering a username! No password field needed... that's why we're here after all! Let's start with building a quick registration form in HTML. Insert the following form into `Resources/Views/index.leaf` just after `\u003c!-- Form --\u003e`:\n\n```html\n\u003cform id=\"registerForm\"\u003e\n    \u003cinput id=\"username\" type=\"text\" /\u003e\n    \u003cbutton type=\"submit\"\u003eRegister\u003c/button\u003e\n\u003c/form\u003e\n```\n\nThe app should now return you a blank HTML form at http://localhost:8080/.\n\n\n### Planning ahead\n\nBefore we jump into the business logic let's write down what we need:\n1. When a user clicks the \"Register\" button we will notify our server about a new registration attempt.\n2. The server will put together a few pieces of information and send these back to the client (the browser).\n3. The client will take this information and pass it into the `create(parseCreationOptionsFromJSON(...))` JavaScript function which will trigger the Passkey prompt. The returned value of this function is our brand new Passkey! Great!\n4. Before opening our first beer we quickly need to send our new Passkey back to the server, verify it and persist it in a database.\n\nIt sounds like a lot of work, but it's actually pretty simple.\n\n### Bringing `\u003cform\u003e` to life\n\nAlright let's start with step one. Add this after the closing `\u003c/form\u003e` tag from the previous step:\n\n```HTML\n\u003cscript type=\"module\"\u003e\n  // import WebAuthn wrapper\n  import { create, parseCreationOptionsFromJSON } from 'https://cdn.jsdelivr.net/npm/@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js';\n\n  // Get a reference to our registration form\n  const registerForm = document.getElementById(\"registerForm\");\n\n  // Listen for the form's \"submit\" event\n  registerForm.addEventListener(\"submit\", async function(event) {\n    event.preventDefault();\n\n    // Get the username\n    const username = document.getElementById(\"username\").value;\n\n    // Send request to server\n    const registerResponse = await fetch('/register?username=' + username);\n\n    // Parse response as json and pass into wrapped WebAuthn API\n    const registerResponseJSON = await registerResponse.json();\n    const passkey = await create(parseCreationOptionsFromJSON(registerResponseJSON));\n  });\n\u003c/script\u003e\n```\n\nFirst we add a third-party script developed by GitHub which adds user-friendly wrappers on top of the original WebAuthn APIs `navigator.credentials.create` and `navigator.credentials.get`. This is just for convenience and not mandatory! If you don't want to use it you'll have to deserialise some of the `registrationOptions` properties since the original API expects a few \"raw\" byte arrays. Using the wrapper we can simply pass in the JSON response from our server - neat! The official WebAuthn API will [support this out of the box at some point](https://w3c.github.io/webauthn/#sctn-parseCreationOptionsFromJSON), but for now we depend on GitHub's \"webauthn-json\" library.\n\nOur script will listen for the form's `submit` event. On submit it sends a `/register` request to our backend and passes the JSON response to `create(parseCreationOptionsFromJSON(...))` thus triggering the browsers Passkey prompt.\n\nIf the user successfully responds to the prompt we'll get a brand new passkey in `const passkey`. Later we will send this passkey to our server and verify it. On the server side of things we still need to add the endpoint we just called in the JavaScript code. In a Vapor app you'd have to register a new route in `routes.swift`:\n\n```swift\napp.get(\"register\") { req in\n    // Create and login user\n    let username = try req.query.get(String.self, at: \"username\")\n    let user = User(username: username)\n    try await user.create(on: req.db)\n    req.auth.login(user)\n\n    // Generate registration options\n    let options = req.webAuthn.beginRegistration(user:\n        .init(\n            id: try [UInt8](user.requireID().uuidString.utf8),\n            name: user.username,\n            displayName: user.username\n        )\n    )\n\n    // Also pass along challenge because we need it later\n    req.session.data[\"registrationChallenge\"] = Data(options.challenge).base64EncodedString()\n\n    return CreateCredentialOptions(publicKey: options)\n}\n```\n\nOn `/register` this creates a new user and calls the `beginRegistration` function with the newly created user. This will give us a set of options which we send back to the client. Additionally we store the challenge in a cookie because we'll need it later when verifying the new Passkey. If you inspect the returned options you'll notice that these are the options you manually entered in your browser's JavaScript console at the beginning of this blog post!\n\nThe WebAuthn API expects the options inside a property named `publicKey`. That's why we return an instance of `CreateCredentialOptions` - a type which doesn't exist yet. So let's create and conform it to `AsyncResponseEncodable` so we can easily return it an a Vapor route handler:\n\n```swift\nstruct CreateCredentialOptions: Encodable, AsyncResponseEncodable {\n    let publicKey: PublicKeyCredentialCreationOptions\n\n    func encodeResponse(for request: Request) async throws -\u003e Response {\n        var headers = HTTPHeaders()\n        headers.contentType = .json\n        return try Response(status: .ok, headers: headers, body: .init(data: JSONEncoder().encode(self)))\n    }\n}\n```\n\nTime to give it a try: Entering a username and clicking \"Register\" should trigger the prompt asking you to create a new Passkey! However nothing will happen afterwards. Let's fix that!\n\n### Verifying and persisting the Passkey\n\nAfter the browser created the Passkey we need to send it to our server, verify everything went smoothly and persist it somewhere.\n\nFirst, let's send the Passkey to our server. In our JavaScript code add this just below `const passkey = await create(parseCreationOptionsFromJSON(registerResponseJSON));` in the `registerForm` event listener:\n\n```JS\nconst createPasskeyResponse = await fetch('/passkeys', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json'\n    },\n    body: JSON.stringify(passkey)\n});\n```\n\nOn the server we first obtain the user we want to register a Passkey for. Then we decode the Passkey from the request body and verify it. If everything went well we can persist the Passkey in our database. Add this logic in a new `POST /register` endpoint:\n\n```swift\n// Example implementation for a Vapor app\napp.post(\"register\", use: { req in\n    // Obtain the user we're registering a credential for\n    let user = try req.auth.require(User.self)\n\n    // Obtain the challenge we stored for this session\n    guard let challengeEncoded = req.session.data[\"registrationChallenge\"],\n        let challenge = Data(base64Encoded: challengeEncoded) else {\n        throw Abort(.badRequest, reason: \"Missing registration challenge\")\n    }\n\n    // Delete the challenge to prevent attackers from reusing it\n    req.session.data[\"registrationChallenge\"] = nil\n\n    // Verify the credential the client sent us\n    let credential = try await req.webAuthn.finishRegistration(\n        challenge: [UInt8](challenge),\n        credentialCreationData: req.content.decode(RegistrationCredential.self),\n        confirmCredentialIDNotRegisteredYet: { _ in true}\n    )\n\n    try await Passkey(\n        id: credential.id,\n        publicKey: credential.publicKey.base64URLEncodedString().asString(),\n        currentSignCount: credential.signCount,\n        userID: user.requireID()\n    ).save(on: req.db)\n\n    return HTTPStatus.ok\n})\n```\n\nCongratulations, you just built a Passkey registration! Entering a username and hitting \"Register\" should now redirect you to a private page. The passkey should also appear in your database (in the passkeys table) now.\n\n## Act 2 - Log in\n\nNow that we have a Passkey we can use it to log in. The process is very similar to the registration process, except we don't need an input field for the username.\nLet's start with the frontend. Add a new HTML form below the registration in `Resources/Views/index.leaf`:\n\n```HTML\n\u003c/form\u003e\n\u003c!-- End of registration form --\u003e\n\n\u003cform id=\"loginForm\"\u003e\n    \u003cbutton type=\"submit\"\u003eLogin\u003c/button\u003e\n\u003c/form\u003e\n```\n\nNext we need to import two additional helper from the GitHub WebAuthn wrapper. Update the import statement in the `\u003cscript\u003e` tag to include `get` and `parseRequestOptionsFromJSON`:\n\n```JS\nimport { create, get, parseCreationOptionsFromJSON, parseRequestOptionsFromJSON } from 'https://cdn.jsdelivr.net.....\n```\n\nAt the end of the script add the following code:\n```JS\n// ...\n//     location.href = \"/private\";\n// });\n\n// Get a reference to our login form\nconst loginForm = document.getElementById(\"loginForm\");\n\n// Listen for the form's \"submit\" event\nloginForm.addEventListener(\"submit\", async function(event) {\n  event.preventDefault();\n  // Send request to Vapor app\n  const loginResponse = await fetch('/login');\n  // Parse response as json and pass into wrapped WebAuthn API\n  const loginResponseJSON = await loginResponse.json();\n  const loginAttempt = await get(parseRequestOptionsFromJSON(loginResponseJSON));\n});\n```\n\nSimilar to the registration we listen for the form's `submit` event. On submit we send a `/login` request to our backend. The response contains a handful of options and a randomly generated challenge. When passing this data to `get(parseRequestOptionsFromJSON(...))` the browser will prompt the user to log in using a Passkey. On success the challenge will be signed by the Passkey. This signed challenge is what we send back to the server in a second request. Add this just after `const loginAttempt = await get(parseRequestOptionsFromJSON(loginResponseJSON));`:\n\n```JS\n// Send passkey to Vapor app\nconst loginAttemptResponse = await fetch('/login', {\n    method: 'POST',\n    headers: {\n        'Content-Type': 'application/json'\n    },\n    body: JSON.stringify(loginAttempt)\n});\n\n// Redirect to private page\nlocation.href = \"/private\";\n```\n\nThis will send the login attempt with the signed challenge to our server and redirect the user to the private page if everything went well. Let's implement the server side of things. First add the endpoint that handles the `GET /login` request returning the options and a randomly generated challenge:\n\n```swift\napp.get(\"login\") { req in\n    // Generate registration options\n    let options = try req.webAuthn.beginAuthentication()\n    // Also pass along challenge because we need it later\n    req.session.data[\"authChallenge\"] = Data(options.challenge).base64EncodedString()\n    return RequestCredentialOptions(publicKey: options)\n}\n```\n\nAdditionally we store the challenge in a cookie because we'll need it later when verifying the Passkey. Running the server and pressing \"Login\" should now trigger the Passkey prompt. If you previously registered it should also show you the username (or a list of usernames if you registered more than one account). However if you try to confirm the prompt you'll notice that nothing happens.\n\nThe last step will be to verify login attempts in the `POST /login` endpoint. Start by adding the endpoint and retrieving the challenge from the users session:\n\n```swift\napp.post(\"login\") { req in\n    // Obtain the challenge we stored on the server for this session\n    guard let challengeEncoded = req.session.data[\"authChallenge\"],\n        let challenge = Data(base64Encoded: challengeEncoded) else {\n        throw Abort(.badRequest, reason: \"Missing authentication challenge\")\n    }\n\n    req.session.data[\"authChallenge\"] = nil\n}\n```\n\nTo prevent attackers from reusing the challenge we delete it from the session right away. Read more about replay attacks [here](https://en.wikipedia.org/wiki/Replay_attack). To verify the login attempt we first decode it from the request body and try to find the corresponding Passkey in our database. If we find a Passkey we can continue and verify the login attempt. Add this below `req.session.data[\"authChallenge\"] = nil`:\n\n```swift\nlet authenticationCredential = try req.content.decode(AuthenticationCredential.self)\n\nguard let credential = try await Passkey.query(on: req.db)\n    .filter(\\.$id == authenticationCredential.id.urlDecoded.asString())\n    .with(\\.$user)\n    .first() else {\n    throw Abort(.unauthorized)\n}\n\nlet verifiedAuthentication = try req.webAuthn.finishAuthentication(\n    credential: authenticationCredential,\n    expectedChallenge: [UInt8](challenge),\n    credentialPublicKey: [UInt8](URLEncodedBase64(credential.publicKey).urlDecoded.decoded!),\n    credentialCurrentSignCount: credential.currentSignCount\n)\n```\n\nFinally if `webAuthn.finishAuthentication` returns without throwing an error we know the login attempt was successful. We can now update the Passkey's `currentSignCount`, sign in the user and return a response just after the call to `req.webAuthn.finishAuthentication`:\n\n```swift\ncredential.currentSignCount = verifiedAuthentication.newSignCount\ntry await credential.save(on: req.db)\n\nreq.auth.login(credential.user)\nreturn HTTPStatus.ok\n```\n\nCongratulations, you just built a Passkey login! Pressing the login button and confirming the Passkey prompt should redirect you to a private page. If you want to see the whole implementation you can find it in the \"final\" directory of the [demo project](https://github.com/brokenhandsio/swift-webauthn-guide).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrokenhandsio%2Fswift-webauthn-guide","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbrokenhandsio%2Fswift-webauthn-guide","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrokenhandsio%2Fswift-webauthn-guide/lists"}