PKCE OAuth and the Meeting SDK for macOS
As of April 17 2026, the Meeting SDK supports using PKCE with a public client ID.
Beginning March 2 2026, apps joining meetings outside their account must be authorized. Authorize apps by using either ZAK or OBF tokens, or RTMS. Learn more.
Authorization code with Proof Key for Code Exchange (PKCE) is an OAuth flow newly supported by Zoom. It is similar to the standard authorization code flow, except it doesn't require that you have a backend server to get the authorization token.
Prerequisites
- Understanding of Swift.
- Version 26 or later required.
- A macOS device running version 10.15 or later.
- Zoom Meeting SDK version 5.9.0 or newer. Version 7.0.2 is preferred.
- Zoom Meeting SDK credentials.
- An authorization URL from your SDK app. This is a URL where users are directed for OAuth.
NOTE Throughout this guide, credentials are hardcoded for convenience. For security reasons, do not store hardcoded credentials of any type in your production application.
After you've created your project, downloaded the Meeting SDK files, integrated the SDK into your app, and gotten the authentication URL from your SDK app, you can authenticate the users with PKCE, get the ZAK token, and start a meeting with that ZAK token.
Authenticate users using PKCE
Now that your project is set up, let's walk through the steps of implementing the PKCE OAuth flow.
Generate the code verifier and challenge
First, generate a code verifier using the next code sample, then hash the verifier to create a code challenge. Host these code verifiers in a dedicated CodeChallengeHelper class. First, define the class with a verifier var in Swift, since the same verifier must be used for the auth session and requesting the access token.
import Foundation
import CommonCrypto
class CodeChallengeHelper {
var verifier: String? = nil
}
Within that same class, define two methods: createCodeVerifier to generate a new verifier and getCodeChallenge to create a code challenge using the verifier.
func createCodeVerifier() {
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
verifier = Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.trimmingCharacters(in: .whitespaces)
}
func getCodeChallenge() -> String {
guard let verifier = verifier,
let data = verifier.data(using: .ascii) else { return "" }
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0, CC_LONG(data.count), &buffer)
}
let hash = Data(buffer)
let challenge = hash.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.trimmingCharacters(in: .whitespaces)
return challenge
}
Start authentication session
After creating the challenge, use it to create the authorization URL and pass that URL into an ASWebAuthenticationSession which will help manage the authentication. For the sake of this guide, we won't go into detail about what this service does. Apple's documentation provides more information for those who are interested.
First, set up the ViewController in your project to conform to ASWebAuthenticationPresentationContextProviding and create a couple of constants for later use.
import Cocoa
import AuthenticationServices
class ViewController: NSViewController {
private let codeChallengeHelper = CodeChallengeHelper()
private let delegate = NSApplication.shared.delegate as! AppDelegate
Next, create a starting point to kick off the actual PKCE logic. Since we're using ASWebAuthenticationSession, you must call the code below after the viewDidAppear callback. Otherwise, your app will not be able to start the session.
Make note of the TODO lines, which require you to input your client ID and redirect URI from your Marketplace OAuth app. As a reminder, do not hardcode credentials in a production environment, as this puts those credentials in reach of any bad actors who try to hack your app.
codeChallengeHelper.createCodeVerifier()
guard var oauthUrlComp = URLComponents(string: "https://zoom.us/oauth/authorize") else { return }
let codeChallenge = URLQueryItem(name: "code_challenge", value: codeChallengeHelper.getCodeChallenge())
let codeChallengeMethod = URLQueryItem(name: "code_challenge_method", value: "S256")
let responseType = URLQueryItem(name: "response_type", value: "code")
let clientId = URLQueryItem(name: "client_id", value: "") // TODO: Enter your OAuth client ID.
let redirectUri = URLQueryItem(name: "redirect_uri", value: "") // TODO: Enter the redirect URI of your OAuth app.
oauthUrlComp.queryItems = [responseType, clientId, redirectUri, codeChallenge, codeChallengeMethod]
let scheme = "" // TODO: Enter the custom scheme of the redirect URI of your OAuth app.
guard let oauthUrl = oauthUrlComp.url else { return }
let session = ASWebAuthenticationSession(url: oauthUrl, callbackURLScheme: scheme) { callbackUrl, error in
self.handleAuthResult(callbackUrl: callbackUrl, error: error)
}
session.presentationContextProvider = self
session.start()
Since you need to provide an instance of ASWebAuthenticationPresentationContextProviding, you will also need to implement presentationAnchor in your ViewController.
extension ViewController : ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return view.window!
}
}
Handle the response
After the OAuth flow redirects to your app via custom URL scheme, the completion handler you passed into the ASWebAuthenticationSession's constructor is invoked. From here, verify that authentication was successful before requesting an access token.
private func handleAuthResult(callbackUrl: URL?, error: Error?) {
guard let callbackUrl = callbackUrl else { return }
if error == nil {
guard let url = URLComponents(string: callbackUrl.absoluteString) else { return }
if let errorParam = url.queryItems?.first(where: { $0.name == "error" })?.value {
handleAuthError(errorParam)
return
}
guard let code = url.queryItems?.first(where: { $0.name == "code" })?.value else { return }
self.delegate.requestAccessToken(code: code, codeChallengeHelper: self.codeChallengeHelper)
}
}
In your AppDelegate, handle the remainder of the OAuth flow starting with the requestAccessToken method invoked in the previous code snippet. Include your client key or secret and redirect URI. As a reminder, do not include hardcoded credentials in a production application.
private func buildAccessTokenBody(code: String, verifier: String?) -> Data? {
var urlComp = URLComponents()
urlComp.queryItems = [
URLQueryItem(name: "grant_type", value: "authorization_code"),
URLQueryItem(name: "code", value: code),
URLQueryItem(name: "redirect_uri", value: ""), // TODO: Input your redirect URI
URLQueryItem(name: "code_verifier", value: verifier)
]
return urlComp.query?.data(using: .utf8)
}
func requestAccessToken(code: String, codeChallengeHelper: CodeChallengeHelper) {
guard let url = URL(string: "https://zoom.us/oauth/token") else { return }
let clientKey = "" // TODO: Enter the client key from your OAuth app.
let clientSecret = "" // TODO: Enter the client secret from your OAuth app. Not used with public client ID.
guard let encoded = "\(clientKey):\(clientSecret)".data(using: .utf8)?.base64EncodedString() else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Basic \(encoded)", forHTTPHeaderField: "Authorization")
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = buildAccessTokenBody(code: code, verifier: codeChallengeHelper.verifier)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard self.checkRequestResult(data: data, response: response, error: error) else { return }
let response = try! JSONDecoder().decode(AccessTokenResponse.self, from: data!)
self.requestZak(accessToken: response.access_token)
}
task.resume()
}
private struct AccessTokenResponse : Decodable {
public let access_token: String
}
Get a ZAK from the REST API
At this point, you should now have an access token which will give you access to the user's ZAK, if your app is correctly scoped. A ZAK can be used to start or join a meeting as that user, so it should be treated as secure credentials.
In both the ZAK request and the access token request, use the checkRequestResult function to avoid repeating the same code.
private func checkRequestResult(data: Data?, response: URLResponse?, error: Error?) -> Bool {
if error != nil { return false }
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { return false }
guard data != nil else { return false }
return true
}
First, build and execute the request to get the ZAK from the token endpoint.
private func requestZak(accessToken: String) {
var urlComp = URLComponents()
urlComp.scheme = "https"
urlComp.host = "api.zoom.us"
urlComp.path = "/v2/users/me/token"
let tokenType = URLQueryItem(name: "type", value: "zak")
urlComp.queryItems = [tokenType]
guard let url = urlComp.url else { return }
var request = URLRequest(url: url)
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard self.checkRequestResult(data: data, response: response, error: error) else { return }
let response = try! JSONDecoder().decode(ZakResponse.self, from: data!)
DispatchQueue.main.async {
self.startMeeting(zak: response.token)
}
}
task.resume()
}
private struct ZakResponse : Decodable {
public let token: String
}
Upon a successful result, parse the ZAK from the response.
Start a meeting with ZAK
Use the ZAK to start a meeting or webinar. This code uses the Meeting SDK to start a meeting, which also presents a ViewController containing the default meeting UI. The Meeting SDK must be called from the main thread, so dispatch to the main thread from your request's completion handler.
private func startMeeting(zak: String) {
let startParams = ZoomSDKStartMeetingUseZakElements()
startParams.meetingNumber = 0
startParams.zak = zak
startParams.userId = "someFakeID"
startParams.displayName = "" // TODO: Enter your display name
startParams.userType = SDKUserType_EmailLogin
let meetingService = zoomSdk.getMeetingService()
meetingService?.delegate = self
let meetingResult = meetingService?.startMeeting(withZAK: startParams)
private func startMeeting(zak: String) {
let startParams = ZoomSDKStartMeetingUseZakElements()
startParams.meetingNumber = 0
startParams.zak = zak
startParams.displayName = "" // TODO: Enter your display name
startParams.userType = SDKUserType_EmailLogin
let meetingService = zoomSdk.getMeetingService()
meetingService?.delegate = self
let meetingResult = meetingService?.startMeeting(withZAK: startParams)
if (meetingResult == ZoomSDKError_Success) {
// The SDK will attempt to join the meeting, see onMeetingStatusChange callback.
}
}
In order to get the meeting status callbacks, your AppDelegate should conform to the ZoomSDKMeetingServiceDelegate protocol.
extension AppDelegate : ZoomSDKMeetingServiceDelegate {
func onMeetingStatusChange(_ state: ZoomSDKMeetingStatus, meetingError error: ZoomSDKMeetingError, end reason: EndMeetingReason) {
if (state == ZoomSDKMeetingStatus_InMeeting) {
// You have successfully joined the meeting.
}
}
}