Creating a Passkey Server for iOS and Android in Swift

Passkeys are a technology for passwordless authentication. There is now a WebAuthn library available for Swift, which combined with Vapor can serve as the backbone for a mobile authentication server.
This series of articles is intended for readers already somewhat familiar with Passkeys and the Swift ecosystem, but I’ll provide links for further reading in areas where those assumptions are made.
Overview
This project will create a restful server to handle Passkey authentication for iOS and Android apps. MySQL is used for the database layer, although any database supported by Vapor can be used (Postgres, MongoDB, etc... see a full list here). I’m also interacting with MySQL from the Vapor application with raw SQL instead of using Vapor’s Fluent ORM. More information on Fluent can be found here: Vapor Fluent.
Setting up a Vapor Project
Creating a new Vapor project is easy. You should be able to follow this guide to create a project and open it in Xcode. Once you have your project open, here’s the Package.swift
file I’m currently using:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: “passkey-server”,
platforms: [
.macOS(.v13)
],
dependencies: [
// 💧 A server-side Swift web framework.
.package(url: “https://github.com/vapor/vapor.git”, from: “4.83.1”),
.package(url: “https://github.com/swift-server/webauthn-swift.git”, from: “0.0.3”),
.package(url: “https://github.com/vapor/mysql-kit.git”, from: “4.7.0”),
.package(url: “https://github.com/vapor/fluent.git”, from: “4.8.0”),
.package(url: “https://github.com/vapor/fluent-mysql-driver.git”, from: “4.4.0”),
],
targets: [
.executableTarget(
name: “App”,
dependencies: [
.product(name: “Vapor”, package: “vapor”),
.product(name: “WebAuthn”, package: “webauthn-swift”),
.product(name: “MySQLKit”, package: “mysql-kit”),
.product(name: “Fluent”, package: “fluent”),
.product(name: “FluentMySQLDriver”, package: “fluent-mysql-driver”),
]
),
.testTarget(name: “AppTests”, dependencies: [
.target(name: “App”),
.product(name: “XCTVapor”, package: “vapor”),
// Workaround for https://github.com/apple/swift-package-manager/issues/6940
.product(name: “Vapor”, package: “vapor”),
])
]
)
Setting up the Database
Here are the create table queries to build the MySQL schema for this project:
CREATE TABLE user (
id varchar(36) NOT NULL,
username varchar(100) DEFAULT NULL,
creation_date datetime DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE userauthchallenge (
user_id varchar(36) NOT NULL,
challenge text NOT NULL,
FOREIGN KEY (user_id)
REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE userchallenge (
user_id varchar(36) NOT NULL,
challenge text,
FOREIGN KEY (user_id)
REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE usercredential (
user_id varchar(36) NOT NULL,
credential_id varchar(100),
public_key text NOT NULL,
FOREIGN KEY (user_id)
REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Once these tables are created, you’ll need to set up the databse connection in your Vapor project. In configure.swift
, add this line to your configure(_ app: Application)function
:
public func configure(_ app: Application) async throws {
…
try setupDatabaseConnection(app)
…
}
private func setupDatabaseConnection(_ app: Application) throws {
var tls = TLSConfiguration.makeClientConfiguration()
tls.trustRoots = .default
tls.minimumTLSVersion = .tlsv11
tls.certificateVerification = .none
// let fileUrl = URL(fileURLWithPath: app.directory.workingDirectory)
// .appendingPathComponent(“Resources/“ + Environment.dbCertPath, isDirectory: false)
//
// tls.trustRoots = NIOSSLTrustRoots.certificates(
// try NIOSSLCertificate.fromPEMFile(fileUrl.path)
// )
let config = MySQLConfiguration(hostname: Environment.dbHostName,
username: Environment.dbUsername,
password: Environment.dbPassword,
database: Environment.defaultDB,
tlsConfiguration: tls)
app.databases.use(.mysql(configuration: config), as: .mysql)
}
For the purposes of this demo I’m setting up a local MySQL database and ignoring TLS settings. I left the actual settings commented out, so hopefully that can serve as a helpful way to get this up and going if needed. I can't remember where exactly I found this information, but it may have come from the vapor discord, it is very active and helpful.
I’m also using environment variables, which require a .env
file in the root directory of your project. An example env entry looks pretty standard, like this: DATABASE_URL=
localhost
.
There’s one more bit of setup I use when I’m working on a vapor project using a database. Even though I’m not using the Fluent ORM, Fluent does help with the management of database connections and exposes a db property on the Request
object. I add a little helper extension for even easier access. This will be used later in the part of the app that handles routes:
import Vapor
import SQLKit
import Fluent
extension Request {
var mySQL: SQLDatabase {
return db as! SQLDatabase
}
}
Public Files for Domain Verification
iOS and Android each require a file to be hosted so that your domain can be verified and associated with your app. The files need to be available publicly, in a .well-known
directory. In order to expose these files publicly, you’ll need to include this in your configure.swift
file, in the configure(app: Application)
function:
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
Apple’s apple-app-site-association File
The apple-app-site-association
file, or AASA, is a JSON file that include’s your app’s identifier, including team ID. Include this code in your routes.swift:
func routes(_ app: Application) throws {
app.get(“.well-known”, “apple-app-site-association”) { req -> Response in
let appIdentifier = “teamId.appIdentifier”
let responseString =
“””
{
“applinks”: {
“details”: [
{
“appIDs”: [
“\(appIdentifier)”
],
“components”: [
]
}
]
},
“webcredentials”: {
“apps”: [
“\(appIdentifier)”
]
}
}
“””
let response = try await responseString.encodeResponse(for: req)
response.headers.contentType = HTTPMediaType(type: “application”, subType: “json”)
return response
}
}
Your Xcode project must also have a corresponding setup. You’ll need to configure Associated Domains in the project settings. Here’s a link to the official documentation. We'll cover that more in the iOS tutorial.
Android’s assetlinks.json File
Android requires a similar file to be hosted, the assetlinks.json
file. This file requires your android app’s identifier, as well as a SHA256 certificate fingerprint for the app. Once you’ve created a keystore for your android app, navigate to your project’s directory in terminal and run this command to generate the fingerprint:
keytool -list -v -keystore PasskeysAndroidKeystore.jks
This is what you’ll need to include in your routes.swift
file. packageName
is your android app identifier:
func routes(_ app: Application) throws {
app.get(“.well-known”, “assetlinks.json”) { req -> Response in
let packageName = “com.example.passkeys_android”
let responseString =
“””
[
{
“relation” : [
“delegate_permission/common.handle_all_urls”,
“delegate_permission/common.get_login_creds”
],
“target” : {
“namespace” : “android_app”,
“package_name” : “\(packageName)”,
“sha256_cert_fingerprints” : [
“YOUR:SHA:FINGERPRINT:GOES:HERE”
]
}
}
]
“””
let response = try await responseString.encodeResponse(for: req)
response.headers.contentType = HTTPMediaType(type: “application”, subType: “json”)
return response
}
}
More information about the assetlinks.json file can be found here.
Setting up WebAuthnManager
This project depends on the great work done on the swift-webauthn library. As of this writing, the official 1.0 release is not out yet, but should be coming soon. WebAuthn is the official name for the standard behind Passkeys, and the swift library is an implementation of WebAuthn that is similar to what has been done for other platforms in different languages. I recommend reading up a WebAuthn a bit if you haven’t already.
One challenge I encountered during this project is that iOS and Android handle Passkeys differently. Android uses a different paradigm for the Relying Party
than Apple/iOS and the rest of the web use. For this reason, we’ll need to set up 2 instances of WebAuthnManager
in our project. One to handle the relying party for our iOS app, and another for the Android app.
First, you’re going to need this extension to set up those managers. I created an Application+WebAuthn.swift
file:
import Vapor
import WebAuthn
extension Application {
struct WebAuthnKey: StorageKey {
typealias Value = WebAuthnManager
}
struct AndroidWebAuthnKey: StorageKey {
typealias Value = WebAuthnManager
}
var webAuthn: WebAuthnManager {
get {
guard let webAuthn = storage[WebAuthnKey.self] else {
fatalError(“WebAuthn is not configured. Use app.webAuthn”)
}
return webAuthn
}
set {
storage[WebAuthnKey.self] = newValue
}
}
var webAuthnAndroid: WebAuthnManager {
get {
guard let webAuthn = storage[AndroidWebAuthnKey.self] else {
fatalError(“WebAuthn for android is not configured. Use app.webAuthnAndroid”)
}
return webAuthn
}
set {
storage[AndroidWebAuthnKey.self] = newValue
}
}
}
extension Request {
var webAuthn: WebAuthnManager {
let userAgent = headers[“user-agent”]
if userAgent.first?.localizedCaseInsensitiveContains(“okhttp”) ?? false {
return application.webAuthnAndroid
}
return application.webAuthn
}
}
Let’s talk a bit more about what’s going on in this extension. Passkeys for the web and iOS use a URL, referred to as the Relying Party
in Passkey terminology. Your iOS or web app might have a relying party of example.com
or login.example.com
, and when the Passkey authentication dance happens, your server will verify the origin of the passkey, which will be https://www.example.com
.
However, Android apps using the CredentialManager
API use a different relying party origin format:
android:apk-key-hash:<sha256-hash-for-your-app's-apk-signing-cert>
Since these formats are completely different, a one-size-fits-all approach doesn't exist for iOS and Android. We have to use 2 different instances of WebAuthnManager
, and look at the request's user agent header to determine if the request came from Android so we can handle Android's unique format. As you can see above, I've built this into an extension on Vapor's Request
so we can easily get the appropriate manager when handling routes.
Side Note -- ngrok
When you're ready to test this out with an iOS or Android app, you're going to need a way to call this server's endpoints with https. If you're just hosting it locally, that's not going to work. Enter ngrok. This tool creates an http or https endpoint and routes the traffic to your local machine, which means you can run this server on your local machine and reach its endpoints both using https and from physical mobile devices (Android will require a physical device, but the iOS simulator will work).
To get started, you'll need to create a free ngrok account and set it up. Then you can follow the instructions here to configure ngrok on your machine. Here's the command I use to run it after it's set up:
./ngrok-2 http 8080
This will create a url that forwards to your localhost on port 8080. This is the default port for running vapor locally in Xcode.
Adding the ngrok url to your environment properties
If everything is set up correctly, ngrok will create a url that looks something like this:
https://5e55-2000-1300-3244-89c0-q049-7106-da49-535g.ngrok-free.app
The full url will be your relying party origin, and the domain without https is your relying party id:
RP_ID=e55-2000-1300-3244-89c0-q049-7106-da49-535g.ngrok-free.app
RP_ORIGIN=https://e55-2000-1300-3244-89c0-q049-7106-da49-535g.ngrok-free.app
You'll need to restart the server after setting environment variables for them to take effect.
Passkey Creation / Sign Up
Current best practice is to require a user to have an account and be authenticated before you allow them to create a Passkey. If you have an existing application, you can offer Passkey creation after a user has signed in. If you're creating something new, some non-Passkey form of account creation and authentication will also need to be created. To keep this project tightly scoped to Passkeys, and provide a working example without getting even deeper into the details, I am not implementing a full login sequence.
Also note that this would also require some of the steps below to be slightly different. A user setting up a passkey would already have a user name and user ID, and you'd know what they are at the time of Passkey creation. You'll want to verify their login credentials before creating a Passkey. You wouldn't want to just let anyone create a Passkey without being authenticated first.
Handling the first sign up step, the user challenge
Users of your client applications will need to create a Passkey the first before they can start authenticating with one. To get the process started, clients will make a GET request to your /signup
endpoint:
app.get("signup", use: { req -> ChallengeResponse in
// 1. the endpoint requires username as a query param
guard let username: String = req.query["username"] else {
throw Abort(.badRequest, reason: "Username must be supplied.")
}
// 2. check to make sure we don't already have that username in the DB
let foundUser = try await req.mySQL
.raw(SQLQueryString("SELECT username FROM user WHERE username = \(bind: username) LIMIT 1"))
.first(decoding: UserResult.self)
guard foundUser == nil else {
throw Abort(.conflict, reason: "Username already taken.")
}
// 3. create a new user credential for the requesting user
return try await makeUserCredential(for: username, req: req)
})
Here's what's going on above:
We grab the
username
query param required by the/signup
endpointWe check to make sure that username doesn't already exist. If it does, this user can't sign up again. If this is a new unique user, we move to step 3
We'll return a
ChallengeResponse
. This is the result of callingmakeUserCredential
, which we'll build next:
func makeUserCredential(for username: String, req: Request) async throws -> ChallengeResponse {
// 1. Create details for the new user and insert into the DB
let newUserId = UUID().uuidString
let timestamp = Date()
try await req.mySQL
.raw(SQLQueryString("INSERT INTO user(id, username, creation_date) VALUES(\(bind: newUserId), \(bind: username), \(bind: timestamp))"))
.run()
// 2. Create a new user and generate a challenge
let user = User(id: newUserId,
username: username,
creationDate: timestamp)
let options = req.webAuthn.beginRegistration(user: user.webAuthnUser)
let challenge = Data(options.challenge).base64EncodedString()
// 3. Save the challenge to the DB and return it back to the client app
try await req.mySQL
.raw(SQLQueryString("INSERT INTO userchallenge(user_id, challenge) VALUES(\(bind: newUserId), \(bind: challenge))"))
.run()
let response = ChallengeResponse(challenge: options,
userId: newUserId)
return response
}
Here's a rundown on what's going on in /makeUserCredential
:
First we create an internal user ID for our new user, and we'll also save their account creation date. You'd already have this for an existing account, but maybe you'd want to track the date of when they created a Passkey. Anyway, we insert the new record into our
user
table.Create a new
User
object and use that to generate aPublicKeyCredentialUserEntity
that we feed into the WebAuthn registration function to create aPublicKeyCredentialCreationOptions
. Thechallenge
is the key piece of information here. We have to convert the byte array to a base 64 string.Finally, we save the challenge data to the database with the user's ID, and send this back in the response to the client app.
At this point, the client app will receive this challenge data and use it to generate a Passkey. In the next step, we'll receive the Passkey from the client in a /makeCredential
endpoint and finish the Passkey registration process.
Handling the second sign up step, creating a credential
The next endpoint we need to implement is the /makeCredential
endpoint (not to be confused with makeUserCredential
above in the previous step. Notice that this is a POST endpoint. The post body is a RegistrationCredential
, which is part of the Swift WebAuthn library in our project. This object gets created by the client app when it creates the user's Passkey.
app.post("makeCredential") { req -> HTTPStatus in
// 1. Find the user we're registering a credential for
guard let userId: String = req.query["userId"] else {
throw Abort(.badRequest, reason: "The user id must be supplied.")
}
guard let user = try await req.mySQL
.raw(SQLQueryString("SELECT * from user WHERE id = \(bind: userId)"))
.first(decoding: User.self) else {
throw Abort(.badRequest, reason: "UserId must be supplied.")
}
// 2. Obtain the challenge we stored on the server for this session
guard let challenge = try await req.mySQL
.raw(SQLQueryString("SELECT * from userchallenge WHERE user_id = \(bind: userId)"))
.first(decoding: ChallengeResult.self),
let challengeData = Data(base64Encoded: challenge.challenge.urlEncoded.base64String()) else {
throw Abort(.badRequest, reason: "Missing registration session id.")
}
// 3. Delete the challenge from the server to prevent an attacker from reusing it
try await req.mySQL.raw(SQLQueryString("DELETE FROM userchallenge WHERE user_id = \(bind: userId)")).run()
// 4. Verify the credential the client sent us
let credential = try await req.webAuthn.finishRegistration(
challenge: [UInt8](challengeData),
credentialCreationData: req.content.decode(RegistrationCredential.self, as: .json),
confirmCredentialIDNotRegisteredYet: { credentialId in
let existingCredential = try await req.mySQL
.raw(SQLQueryString("SELECT * FROM usercredential WHERE credential_id = \(bind: credentialId)"))
.first(decoding: CredentialResult.self)
return existingCredential == nil
}
)
// 5. If the credential was verified, save it to the database
let authnCredential = WebAuthnCredential(from: credential, userId: user.id)
try await req.mySQL
.raw(SQLQueryString("INSERT INTO usercredential(user_id, credential_id, public_key) VALUES(\(bind: user.id), \(bind: authnCredential.id), \(bind: authnCredential.publicKey))"))
.run()
return .ok
}
There's a going on here, but it's not too hard to understand. Let's break it down:
The client app sends us the user id we created in the previous step so we can identify the user and make sure we already know about them.
Now we use that user id to grab the user's challenge data that we stored during the first stage of Passkey registration in the previous endpoint's implementation. We're going to take that challenge string, url encode it, base 64 encode it, and convert it to
Data
.- Note: Get used to base 64 and url encoded strings. This is going to be a really common theme throughout a Passkey implementation.
Now that we have our
challengeData
stored in memory, we're going to delete that challenge from the database. This ensures that the challenge can't be used again by an attacker.There's a lot packed into this step. The first param of the WebAuthn
finishRegistration
function is ourchallengeData
converted to a byte array. We're also decoding the post body of the request into aRegistrationCredential
and making sure that credential's id doesn't already exist in our DB with theconfirmCredentialIDNotRegisteredYet
closure. If everything succeeds, the credential is verified and we'll get aCredential
object.Now we create a homemade object,
WebAuthnCredential
, and we'll use that to help save our userId, credentialId, and publicKey to the database. If everything succeeds, we'll just return a 200 ok response to the client app.
Phew! Registration is now complete.
Passkey Authentication
Generating an Auth Challenge
You'll need to create a GET
endpoint named /authentication
. It takes a username
field as a query param, which will most likely be the user's email address.
app.get("authenticate") { req -> PublicKeyCredentialRequestOptions in
guard let username: String = req.query["username"] else {
throw Abort(.badRequest, reason: "Username must be supplied.")
}
// 1. generate a challenge for the user attempting to log in
let options = try req.webAuthn.beginAuthentication()
let optionDataString = Data(options.challenge).base64EncodedString()
guard let existingUser = try await req.mySQL
.raw(SQLQueryString("SELECT * FROM user WHERE username = \(bind: username)"))
.first(decoding: User.self) else {
throw Abort(.badRequest, reason: "User not found.")
}
// 2. save that challenge to the database as a base64 encoded string
try await req.mySQL
.raw(SQLQueryString("INSERT INTO userauthchallenge(user_id, challenge) VALUES(\(bind: existingUser.id), \(bind: optionDataString))"))
.run()
// 3. return the generated credential request options back to the caller
return options
}
This is all pretty straightforward:
After making sure we received a username as a query param, we use our
Request
extension created earlier to get the rightWebAuthnManager
instance and callbeginAuthentication()
. This generates a challenge for this authentication attempt. We also convert it to a base64 encoded string so we can save it to the database.Save that challenge to the database, along with the user's ID
Return the
PublicKeyCredentialRequestOptions
back to the caller so they can perform the next authentication step.
Finishing Authentication
Next we'll create a POST
endpoint called /authenticate
. We're also taking the username as a query parameter again, and also require an AuthenticationCredential
as a post body. Here's the implementation:
app.post("authenticate") { req -> HTTPStatus in
// 1. Get the user from the query param and make sure we know who it is
guard let username: String = req.query["username"] else {
throw Abort(.badRequest, reason: "Username must be supplied.")
}
guard let existingUser = try await req.mySQL
.raw(SQLQueryString("SELECT * FROM user WHERE username = \(bind: username)"))
.first(decoding: User.self) else {
throw Abort(.badRequest, reason: "User not found.")
}
// 2. Make sure we have a challenge saved for this user
guard let challenge = try await req.mySQL
.raw(SQLQueryString("SELECT * from userauthchallenge WHERE user_id = \(bind: existingUser.id)"))
.first(decoding: ChallengeResult.self),
let challengeData = Data(base64Encoded: challenge.challenge.urlEncoded.base64String()) else {
try await removeAuthChallenge(for: existingUser.id, req: req)
throw Abort(.badRequest, reason: "Missing auth session id.")
}
// 3. Delete the challenge from the server to prevent an attacker from reusing it
try await removeAuthChallenge(for: existingUser.id, req: req)
// 4. Decode the credential the client sent us
var authenticationCredential = try req.content.decode(AuthenticationCredential.self)
let authenticatorData = authenticationCredential.response.authenticatorData
let authString = String(bytes: authenticatorData, encoding: .utf8)
// 5. Make sure we have an auth credential saved in our database for this user
guard let foundCredential = try await req.mySQL
.raw(SQLQueryString("SELECT * FROM usercredential WHERE credential_id = \(bind: authenticationCredential.id.urlDecoded)"))
.first(decoding: UserAuthCredential.self) else {
try? await removeAuthChallenge(for: existingUser.id, req: req)
throw Abort(.badRequest)
}
let credentialPublicKey = URLEncodedBase64(foundCredential.publicKey).urlDecoded
guard let decodedPublicKey = credentialPublicKey.decoded else {
try? await removeAuthChallenge(for: existingUser.id, req: req)
throw Abort(.badRequest)
}
// 6. If we found a credential, use the stored public key to verify the challenge
// sign count will always be 0, we don't need to do anything else with it
_ = try req.webAuthn.finishAuthentication(
credential: authenticationCredential,
expectedChallenge: [UInt8](challengeData),
credentialPublicKey: [UInt8](decodedPublicKey),
credentialCurrentSignCount: 0)
return .ok
}
func removeAuthChallenge(for userId: String, req: Request) async throws {
try await req.mySQL.raw(SQLQueryString("DELETE FROM userauthchallenge WHERE user_id = \(bind: userId)")).run()
}
Ok, this is a bit of a longer function, but hopefully things are coming together in a way that makes sense:
First we're just making sure the client sent a username and that we know enough about that username to do something with it
We also need to make sure we've previously saved a challenge for this user. We need to decode that back to
Data
from it's base64 url encoded format we saved to the database.Similar to some previous spots, we're going to remove this challenge from our database now. If an attacker attempted a replay attack, we won't fall for it because we've already had a request come through for this user and we've cleaned up.
It's time to decode the
AuthenticationCredential
that was sent in the payload of this request. In particular, we're looking for theauthenticatorData
piece, and we convert that to a String.Now we check if we've previously saved a
UserAuthCredential
to the database for this user. This contains the public key we'll use to validate the user's credential. Again, if we don't find anything we stored previously in the database, we're deleting the challenge for this user. If anything smells off, we err on the side of caution. Worst case, a legit user will get a failure and can just try again.The WebAuthn library validates the credential. If everything lines up, success!
At this point, instead of sending an empty 200 response, you'd most likely want to send a token in the response that your user will pass with every subsequent request, granting them access to your server's endpoints until the token expires. If you're adding Passkeys to an existing application, you probably already have this in place. If you're starting from scratch, REST API token management
would be a great next rabbit hole to go down.
Conclusion
Thanks for reading this far...we've covered a lot! Hopefully I've given you enough information to dive deeper on any areas that aren't as clear or you are interested in learning more about.
This is the first in a series of articles on implementing a Passkey authentication server that can serve iOS and Android mobile apps. The other 2 articles can be found here:
iOS:
Android:
Sources
The following articles were helpful or served as inspiration:
Subscribe to my newsletter
Read articles from Jeff Day directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
