Home

Sign in with Apple using SwiftUI and Vapor

14 May 2026

Introduction to Sign in with Apple

Since I only develop for Apple platforms, I choose Sign in with Apple for any app I create that requires a user account.

While it is often preferable to avoid the need for user accounts, there are many cases where content created by a user must be tied to them in some way. As an independent developer I don’t want the responsibility of securely storing user identifying information. Using Sign in with Apple (or SIWA for short) the majority of that burden is passed off to Apple, while my app only receives minimal user information. That information consists of a random but unique identifier for each user, their email address (which may be altered using Hide my email) and full name.

There are other benefits to SIWA, such as determining realUserStatus which is a series of checks Apple does on their end to validate whether an account belongs to a real human. As well as userAgeRange which declares if the user is considered an adult or not but will not share their actual age or birthday.

Let’s get into how we can use Sign in with Apple in a SwiftUI App, and equally importantly, how to tie SIWA into our Vapor backend.

Add Sign in with Apple Capability

First you’ll need to add the Sign in with Apple capability to your App Target.

In Xcode select your App name at the top-most level of the file structure. You should see sections titled Project and Targets. Make sure your app is selected under Targets and you should see more options at the right such as General, Signing & Capabilities, Resource Tags and so on.

Select Signing & Capabilities and click the “+ Capability” button. From there you’ll be presented with the many options available in an App. You can scroll down, or simply start typing Sign in with Apple and the option should come up. Click Sign in with Apple and the capability will be added to your app’s entitlements.

Add Sign in with Apple Button

In SwiftUI there is a dedicated View that creates the Sign in with Apple button. In the View where you want your button you’ll need to import AuthenticationServices to the file. You can add this line above the import SwiftUI.

We are finally ready to add the SIWA button! For this example I am going to have my SIWA button say  Sign in with Apple by using the .signIn modifier, but you can choose from a couple different options.

// SIWA Button View
SignInWithAppleButton(.signIn) { appleIDRequest in
	appleIDRequest.requestedScopes = [.email, .fullName]
} onCompletion: { result in
	// handle result
	handleSignInWithApple(result: result)
}.frame(width: 200, height: 50)

A few notes about the code above.

The appleIDRequest is of type ASAuthorizationAppleIDRequest and is defined by Apple as An OpenID authorization request that relies on the user’s Apple ID. We can opt to receive the user’s email and full name from the ID Request. Important! You will only receive this information on the first sign-in request. So be sure and save it to your app and/or server if you want to associate those values to a user account in the future.

The onCompletion handler is of type Result<ASAuthorization, any Error>. We are going to process that result in a separate method which I have named handleSignInWithApple(result:).

You’ll also notice I’ve added a .frame(width: 200, height: 50). For some reason a SIWA Button will choose to take up the entire view if a frame is not defined. I’ve found that 200x50 is a nice size, but this is a size you can certainly play around with.

In the completion handler of our SIWA button, we will process the result with the method below. I’ve marked several lines with comments to describe what is happening as we go.

private func handleSignInWithApple(result: Result<ASAuthorization, any Error>) {
	switch result {
		case .success(let authorizedUser):
		if let verifiedUser = authorizedUser.credential as? ASAuthorizationAppleIDCredential {
			/// This is a unique and stable ID provided to the user through SIWA.
			/// It is not a UUID! It looks something like this: 000111.42meud...20f13b39xj.0000 
			let stableUserID = verifiedUser.user

			switch verifiedUser.realUserStatus {
				case .likelyReal:
					/// We'll cover this method in a moment, but it's where we send the 
					/// identity token issued by Apple as well as other user information to our server.
					guard let tokenPayload = buildTokenPayload(from: verifiedUser) else { 
						// handle error
					return 
					}
					Task {
						do {
						/// Send the `tokenPayload` to the server for verification of the user.
						/// I'll explain the `verify(token:)` later in this post.
						let bearerTokenResponse = try await verify(token: tokenPayload)

						/// Personally I securely save the BearerToken to the iOS Keychain. If the user has no BearerToken,
						/// or it's expired, trigger the SIWA flow again. You can handle the bearerToken a different way if you choose.
						try saveBearerTokenToKeychain(with: stableUserID, bearerToken: bearerTokenResponse.bearerToken)
					} catch {
						// handle error
					}
				}
		   case .unknown, .unsupported:
			   // The system can't determine if this is a real person.
			   // Dismiss, throw error, or handle however you choose.
			default:
				// handle error
			}
		}
	case .failure(let failure):
		 // handle error
}
Creating the buildTokenPayload method.

The PersonSIWARegistration is the payload to send to your backend. You’ll construct this to suit your needs, but you need to send the identityToken at the very least. The identityToken is a JSON Web Token and will be used by Vapor to verify the user with Apple’s servers. The appleUserID, name, and email are included in the payload as values you’ll probably want to save to your server. But it’s really up to you on what information you want to save. In my own app I populated the name property with a SwiftUI TextField but you could also use the ASAuthorizationAppleIDCredential.fullName instance property.

struct PersonSIWARegistration: Codable {
	let identityToken: String
	let appleUserID: String
	let name: String
	let email: String
}
func buildTokenPayload(from credential: ASAuthorizationAppleIDCredential) -> PersonSIWARegistration? {
	/// Apple issues a signed JWT (the .identityToken) to the iOS client
	/// We convert it to a String because the verification method on the Vapor side expects a String.
	guard let rawToken = credential.identityToken,
		  let tokenAsString = String(data: rawToken, encoding: .utf8) else {
		// handle error
		return nil
	}

	/// The payload to send to our server
	return PersonSIWARegistration(
		identityToken: tokenAsString,
		appleUserID: credential.user,
		email: credential.email ?? "email not received",
		name: name
	)
}

Scroll back up to the handleSignInWithApple(result:) method to refresh your memory about where we’re using the buildTokenPayload method.

Send SIWA Data to Server

The next method call in handleSignInWithApple(result:) is the verify(token:) method. This is where we send the payload to our server and we’ll get a BearerToken back. Please note that the URL and path is going to be unique to your specific server. I’ve added a possible path in the URL for example purposes.

/// The client sends our SIWAIdentityToken to our Vapor server.
/// Our server will verify the token with Apple.
/// After verification the server will return a BearerToken.
private func verify(token: PersonSIWARegistration) async throws -> BearerTokenDTO {
	// This URL path is YOUR Endpoint
	guard let url = URL(string: "https://your-server-address.com/path/siwaEndpoint") else {
		throw Error
	}

	var request = URLRequest(url: url)
	request.httpMethod = HttpMethod.POST.rawValue
	request.addValue(MIMEType.JSON.rawValue, forHTTPHeaderField: HttpHeader.contentType.rawValue)
	request.httpBody = try JSONEncoder().encode(token)

	do {
		let (data, response) = try await URLSession.shared.data(for: request)
		guard (response as? HTTPURLResponse)?.statusCode == 200 else {
			let errorResponse = (response as? HTTPURLResponse)?.statusCode
			throw Error
		}

		let token = try JSONDecoder().decode(BearerTokenDTO.self, from: data)
		return token
	} catch {
		throw Error
	}
}
Save Bearer Token to Keychain

Personally I save the BearerToken to the user’s Keychain on the device for safe keeping. At app launch, or whenever is appropriate, you can retrieve the BearerToken. As long as it is present and valid, the user can be granted permission to use your app or feature. The code for the KeychainManager is also below for your reference.

private func saveBearerTokenToKeychain(with userID: String, bearerToken: String) throws {
	// Save the userID to Keychain
	let savedUserID = KeychainManager.shared.save(userID, forKey: "SomeID For The User")
	let savedBearerToken = KeychainManager.shared.save(bearerToken, forKey: "The Bearer Token for that User")

	guard savedUserID, savedBearerToken else {
		throw ErrorType.saveError
	}
}
Keychain Manager

This is the KeychainManager I referenced above to securely store the BearerToken. If you have another method for storing the BearerToken you can skip over this code block. I've used this in a couple of apps and seems to work well for me. ***Disclaimer*** It was generated using AI and I couldn't explain how it works. If you have improvements or other suggestions I am open to discussion.

import Foundation

struct KeychainManager {
	static let shared = KeychainManager()

	private init() { }

	func save(_ value: String, forKey key: String) -> Bool {
		guard let data = value.data(using: .utf8) else { return false }

		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrAccount as String: key,
			kSecValueData as String: data
		]

		// Delete any existing item with the same key
		SecItemDelete(query as CFDictionary)

		// Add the new key-value pair to the keychain
		let status = SecItemAdd(query as CFDictionary, nil)

		return status == errSecSuccess
	}

	func retrieve(forKey key: String) -> String? {
		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrAccount as String: key,
			kSecReturnData as String: true,
			kSecMatchLimit as String: kSecMatchLimitOne
		]

		var item: CFTypeRef?
		let status = SecItemCopyMatching(query as CFDictionary, &item)

		guard status == errSecSuccess, let data = item as? Data, let result = String(data: data, encoding: .utf8) else {
			return nil
		}

		return result
	}

	func delete(forKey key: String) -> Bool {
		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrAccount as String: key
		]

		let status = SecItemDelete(query as CFDictionary)

		return status == errSecSuccess
	}
}
SIWA with Vapor

We’ve done all of our work on the client side. But what now? Since you’re reading this blog you must be using Vapor for your backend so we’ll set up the code on our server to handle Sign in with Apple requests.

Assuming you already have a Vapor project set up, we’ll add the jwt package to your project. In the Package.swift file, under “dependencies” add this line .package(url: “https://github.com/vapor/jwt.git”, from: “5.0.0”) Then in the dependencies array add this line .product(name: “JWT”, package: “jwt”).

The whole Package.swift file should look about like this;

import PackageDescription

let package = Package(
	name: "RelayTeamFinderAPI",
	platforms: [
	   .macOS(.v13)
	],
	dependencies: [
		// 💧 A server-side Swift web framework.
		.package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
		// 🗄 An ORM for SQL and NoSQL databases.
		.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
		// 🐘 Fluent driver for Postgres.
		.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
		// 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
		.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
		// Sign in with Apple
		.package(url: "https://github.com/vapor/jwt.git", from: "5.0.0"),
	],
	targets: [
		.executableTarget(
			name: "RelayTeamFinderAPI",
			dependencies: [
				.product(name: "Fluent", package: "fluent"),
				.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
				.product(name: "Vapor", package: "vapor"),
				.product(name: "NIOCore", package: "swift-nio"),
				.product(name: "NIOPosix", package: "swift-nio"),
				.product(name: "JWT", package: "jwt")
			],
			swiftSettings: swiftSettings
		),
		.testTarget(
			name: "RelayTeamFinderAPITests",
			dependencies: [
				.target(name: "RelayTeamFinderAPI"),
				.product(name: "VaporTesting", package: "vapor"),
			],
			swiftSettings: swiftSettings
		)
	]
)

var swiftSettings: [SwiftSetting] { [
	.enableUpcomingFeature("ExistentialAny"),
] }

Now click down to the configure file. Your server needs to store a JWT Secret to process the token received from Apple. I won’t go into how to generate and store that secret because it depends on which backend you’re using, but some Googling around should get that answer to you.

After generating your secret and storing it in your server’s environment, place this block of code below the app.databases.use block of code where you already see values like username, password and database being retrieved as Environment values.

guard let secret = Environment.get("JWT_SECRET") else {
app.logger.critical("JWT_SECRET not set in environment")
throw Abort(.internalServerError, reason: "JWT_Secret not set")
}

Finally add this line below your `secret` property;

await app.jwt.keys.add(hmac: .init(from: secret), digestAlgorithm: .sha384)
BearerToken Model

We're done with the setup to handle JWT's using Vapor. I am going to gloss over the construction of a BearerToken on the server just to keep this post remotely close to a reasonable length. Below is the Model I use on my server which constructs a BearerToken and assigns it as a child of a Person/User. You'll see a static method in the Model which generates a random UInt8 as a BearerToken. You can research how you'd like to construct your BearerToken, but the important part is you assign it to your user is some way.

final class BearerToken: Model, Content, @unchecked Sendable {
	static let schema = Path.bearerToken.rawValue

	@ID(key: .id)
	var id: UUID?

	@Field(key: "bearerToken")
	var bearerToken: String

	// Each Person is the parent of their own BearerToken
	@Parent(key: "personID")
	var person: Person

	init() { }

	init(id: UUID? = nil, bearerToken: String, personID: Person.IDValue) {
		self.id = id
		self.bearerToken = bearerToken
		self.$person.id = personID
	}

	/// This gets called in the PersonController `handleSignInWithApple` method.
	static func generate(for person: Person) throws -> BearerToken {
		let random = [UInt8].random(count: 16).base64
		return try BearerToken(bearerToken: random, personID: person.requireID())
	}
}

Because of the @Parent relationship on the BearerToken, I’ve constructed a Domain Transfer Object to simplify the return object

struct BearerTokenDTO: Content {
   let bearerToken: String
   let personID: UUID
}
The SIWA Endpoint

The nice thing about handling Sign in with Apple on the server is it can be done in one relatively clean function. I’ve made a lot of comments in line so pay attention to those for a description of what each property is for. If you remember, we are calling this endpoint from our iOS app using the verify(token: PersonSIWARegistration) method to get the BearerToken. Scroll back up to refresh your memory. Here is the Vapor side code.

@Sendable
func handleSignInWithApple(req: Request) async throws -> BearerTokenDTO {
	// Much like we did with the JWT, store the appIdentifier in your server's environment values
	// The app identifier, also called the Bundle Identifier can be found in your App's Xcode General settings and looks something like this: "com.TeamID.MyAppName"
	guard let appIdentifier = Environment.get("IOS_APPLICATION_IDENTIFIER") else {
		throw Abort(.notFound, reason: "No App Identifier found in the server environment values")
	}

	/// The `identityToken` in the data is the token provided to the client when the user tapped the SIWA button.
	let data = try req.content.decode(PersonSIWARegistration.self)

	/// Use Vapor's built-in `jwt.apple.verify` method, using the JWT identityToken sent over from our client
	/// along with the appIdentifier, we'll get a token back from Apple
	let appleIdentityToken = try await req.jwt.apple.verify(data.identityToken, applicationIdentifier: appIdentifier)

	/// The subject value is Apple's stable, permanent ID for this user.
	/// This is the same ID for the user as our `stableUserID` on the client side. It's provided in the JWT and
	/// verified on Apple's servers as authentic.
	let appleUserID = appleIdentityToken.subject.value

	// This is an example Person model you could save to the Vapor database.
	let newPerson = Person(
		siwaID: appleUserID,
		name: data.name,
		email: data.email
	)
	// Create and save the new Person
	try await newPerson.save(on: req.db)

	// Create and save the new token.
	let token = try BearerToken.generate(for: newPerson)
	try await token.save(on: req.db)
	return BearerTokenDTO(bearerToken: token.bearerToken, personID: token.$person.id)
}

There is no doubt a lot of little steps to using Sign in with Apple. Most blogs I've seen only talk about the client side code and leave out what I feel is a very important aspect, the server side! We can store user data associated with their stable identifier on the server for future use or to associate entitlements with that user. The fact that there is a stable userID across devices and installs really helps to maintain a consistent user experience over the long term.

Thank you for reading this far, I hope it was helpful. In writing this blog I am not only trying to help others build their apps, but I am also learning myself. So if you see errors or major omissions, please don't hesitate to reach out to me on my socials below.

Supporting Links

Connect with Dan: Bluesky Instagram

Download The Midst on the App Store

Find me on The Midst: dan

SIWA Button Documentation Vapor Documentation

Created in Swift with Ignite