Introduction: Why OAuth 2.0 and JWT Are the Standard for iOS Apps
\nModern mobile applications rarely work in isolation. Most of them interact with remote APIs, requiring secure user authentication and authorization. In the Apple ecosystem (iOS, macOS, watchOS), the de facto standard has become the combination of OAuth 2.0 (an access delegation protocol) and JWT (JSON Web Token — a compact token format). This combination provides flexibility, security, and ease of integration.
\nIn this article, we will not delve into dry theory. Instead, we will walk through a practical implementation in Swift. You will learn how to obtain an access token via OAuth 2.0, how to securely store it in Keychain, how to decode JWT on the client side, and how to refresh tokens without user intervention. All code examples are ready for use in a real project.
\n\n1. OAuth 2.0 Architecture in Swift: From Request to Token
\nFirst, let's define the flow. In mobile applications, the Authorization Code Flow with PKCE (Proof Key for Code Exchange) is most commonly used. This is the most secure option for public clients (mobile applications cannot store a secret).
\nLet's create a structure for OAuth configuration:
\nstruct OAuthConfig {\n let clientID: String\n let redirectURI: String\n let authorizationEndpoint: String\n let tokenEndpoint: String\n let scope: String\n}\nThe first step is to form the authorization URL. For PKCE, we need to generate a codeVerifier and its SHA256 hash — codeChallenge:
import Foundation\nimport CommonCrypto\n\nclass PKCEGenerator {\n static func generateCodeVerifier() -> String {\n var buffer = [UInt8](repeating: 0, count: 32)\n _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)\n return Data(buffer).base64URLEncodedString()\n }\n \n static func generateCodeChallenge(from verifier: String) -> String {\n guard let data = verifier.data(using: .utf8) else { return "" }\n var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))\n data.withUnsafeBytes { \n _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)\n }\n return Data(hash).base64URLEncodedString()\n }\n}\n\nextension Data {\n func base64URLEncodedString() -> String {\n return self.base64EncodedString()\n .replacingOccurrences(of: "+", with: "-")\n .replacingOccurrences(of: "/", with: "_")\n .replacingOccurrences(of: "=", with: "")\n }\n}\nNow we form the URL to open in the browser (via ASWebAuthenticationSession):
func buildAuthorizationURL(config: OAuthConfig, codeChallenge: String) -> URL? {\n var components = URLComponents(string: config.authorizationEndpoint)\n components?.queryItems = [\n URLQueryItem(name: "response_type", value: "code"),\n URLQueryItem(name: "client_id", value: config.clientID),\n URLQueryItem(name: "redirect_uri", value: config.redirectURI),\n URLQueryItem(name: "scope", value: config.scope),\n URLQueryItem(name: "code_challenge", value: codeChallenge),\n URLQueryItem(name: "code_challenge_method", value: "S256")\n ]\n return components?.url\n}\nAfter successful authorization, the user will be redirected to redirectURI with a code parameter. This code is exchanged for tokens:
func exchangeCodeForToken(config: OAuthConfig, code: String, codeVerifier: String) async throws -> TokenResponse {\n let url = URL(string: config.tokenEndpoint)!\n var request = URLRequest(url: url)\n request.httpMethod = "POST"\n request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")\n \n let body = "grant_type=authorization_code" +\n "&co