이 글은 누구를 위한 것인가
- 모바일 앱에 소셜 로그인이나 OAuth 인증을 구현해야 하는 개발자
- Implicit Flow를 아직 쓰고 있거나, 앱 내 WebView로 로그인을 처리하는 팀
- 토큰을 어디에, 어떻게 저장해야 안전한지 혼란스러운 엔지니어
들어가며
모바일 앱에서 OAuth 2.0을 구현할 때 흔히 저지르는 실수가 있다. 웹에서 쓰는 Implicit Flow를 그대로 쓰거나, WebView 안에서 로그인 페이지를 열거나, 토큰을 UserDefaults에 저장하는 것이다. 이 모두 보안 취약점이다.
RFC 8252 "OAuth 2.0 for Native Apps"와 PKCE(Proof Key for Code Exchange)가 모바일의 표준이다. 복잡해 보이지만 구조를 이해하면 단순하다.
이 글은 bluefoxdev.kr의 모바일 보안 인증 가이드 를 참고하고, PKCE 실전 구현 관점에서 확장하여 작성했습니다.
1. 왜 PKCE인가
1.1 Authorization Code Flow의 위험
[일반 Authorization Code Flow - 앱에서 위험한 이유]
앱 → 브라우저에서 인증 URL 오픈
브라우저 → 인증 서버에서 code 발급
인증 서버 → 앱으로 code 리다이렉트 (custom scheme)
앱 → code로 token 교환
위험: 악의적인 앱이 동일한 custom scheme 등록 가능
→ code 가로채기 → 토큰 탈취
1.2 PKCE가 해결하는 방법
[PKCE Flow]
1. 앱: code_verifier(랜덤 문자열) 생성
2. 앱: code_challenge = SHA256(code_verifier)
3. 앱 → 인증 서버: code_challenge 포함해서 인증 요청
4. 브라우저: 인증 후 code 발급
5. 악의적인 앱이 code를 가로채도...
code + code_verifier 없으면 토큰 교환 불가!
6. 정상 앱 → 인증 서버: code + code_verifier로 토큰 교환
2. iOS 구현
2.1 ASWebAuthenticationSession 사용
import AuthenticationServices
import CryptoKit
class OAuthManager: NSObject {
// PKCE 파라미터 생성
private func generatePKCE() -> (verifier: String, challenge: String) {
// code_verifier: 43~128자의 랜덤 URL-safe 문자열
var buffer = [UInt8](repeating: 0, count: 64)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
let verifier = Data(buffer).base64URLEncodedString()
// code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
let data = Data(verifier.utf8)
let hashed = SHA256.hash(data: data)
let challenge = Data(hashed).base64URLEncodedString()
return (verifier, challenge)
}
func startOAuthFlow(
authorizationURL: URL,
clientID: String,
redirectURI: String,
scopes: [String],
completion: @escaping (Result<TokenResponse, Error>) -> Void
) {
let (verifier, challenge) = generatePKCE()
// state: CSRF 방지
let state = UUID().uuidString
var components = URLComponents(url: authorizationURL, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "state", value: state),
]
// verifier를 메모리에 임시 저장 (세션 중)
let storedVerifier = verifier
let storedState = state
// ASWebAuthenticationSession: 시스템 브라우저 사용 (앱 내 WebView 아님)
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: "myapp"
) { callbackURL, error in
guard let callbackURL, error == nil else {
completion(.failure(error ?? OAuthError.cancelled))
return
}
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)
let code = components?.queryItems?.first(where: { $0.name == "code" })?.value
let returnedState = components?.queryItems?.first(where: { $0.name == "state" })?.value
// state 검증 (CSRF 방지)
guard returnedState == storedState, let code else {
completion(.failure(OAuthError.stateMismatch))
return
}
// 토큰 교환
Task {
do {
let token = try await self.exchangeCodeForToken(
code: code,
verifier: storedVerifier,
redirectURI: redirectURI
)
completion(.success(token))
} catch {
completion(.failure(error))
}
}
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // 쿠키 공유로 SSO 지원
session.start()
}
private func exchangeCodeForToken(
code: String,
verifier: String,
redirectURI: String
) async throws -> TokenResponse {
var request = URLRequest(url: tokenEndpoint)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let body = [
"grant_type=authorization_code",
"code=\(code)",
"redirect_uri=\(redirectURI)",
"client_id=\(clientID)",
"code_verifier=\(verifier)",
].joined(separator: "&")
request.httpBody = body.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw OAuthError.tokenExchangeFailed
}
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
}
2.2 토큰 안전 저장 (Keychain)
import Security
class TokenStorage {
private let service = "com.myapp.auth"
func save(token: String, forKey key: String) throws {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
// 기기 잠금 해제 시에만 접근 가능
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
// 기존 값 삭제 후 저장
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func load(forKey key: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
func delete(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
3. Android 구현
3.1 AppAuth-Android 사용
// build.gradle.kts
dependencies {
implementation("net.openid:appauth:0.11.1")
}
class OAuthActivity : AppCompatActivity() {
private lateinit var authService: AuthorizationService
private val RC_AUTH = 100
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authService = AuthorizationService(this)
}
fun startOAuthFlow() {
val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://auth.example.com/authorize"),
Uri.parse("https://auth.example.com/token")
)
val request = AuthorizationRequest.Builder(
serviceConfig,
"client_id",
ResponseTypeValues.CODE,
Uri.parse("com.myapp://callback")
)
.setScope("openid profile email")
.setCodeVerifier() // PKCE 자동 처리 (AppAuth가 관리)
.build()
val intent = authService.getAuthorizationRequestIntent(request)
startActivityForResult(intent, RC_AUTH)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == RC_AUTH) {
val response = AuthorizationResponse.fromIntent(data!!)
val exception = AuthorizationException.fromIntent(data)
if (response != null) {
exchangeCodeForTokens(response)
} else {
Log.e("OAuth", "Authorization failed: ${exception?.message}")
}
}
}
private fun exchangeCodeForTokens(response: AuthorizationResponse) {
val tokenRequest = response.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
if (tokenResponse != null) {
// 토큰 저장
saveTokens(tokenResponse)
} else {
Log.e("OAuth", "Token exchange failed: ${exception?.message}")
}
}
}
override fun onDestroy() {
super.onDestroy()
authService.dispose()
}
}
3.2 토큰 안전 저장 (EncryptedSharedPreferences)
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"auth_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveAccessToken(token: String) {
prefs.edit().putString("access_token", token).apply()
}
fun getAccessToken(): String? {
return prefs.getString("access_token", null)
}
fun saveRefreshToken(token: String) {
prefs.edit().putString("refresh_token", token).apply()
}
fun getRefreshToken(): String? {
return prefs.getString("refresh_token", null)
}
fun clearAll() {
prefs.edit().clear().apply()
}
}
4. Refresh Token 처리 패턴
// iOS: 토큰 갱신 인터셉터 패턴
actor TokenRefresher {
private var refreshTask: Task<String, Error>?
func validAccessToken() async throws -> String {
// 저장된 토큰 만료 확인
if let token = tokenStorage.accessToken, !token.isExpired {
return token.value
}
// 이미 갱신 중이면 기다림 (중복 갱신 방지)
if let existingTask = refreshTask {
return try await existingTask.value
}
let task = Task<String, Error> {
defer { refreshTask = nil }
guard let refreshToken = tokenStorage.refreshToken else {
throw OAuthError.noRefreshToken
}
let newToken = try await oauthManager.refresh(token: refreshToken)
tokenStorage.save(newToken)
return newToken.accessToken
}
refreshTask = task
return try await task.value
}
}
5. 보안 체크리스트
모바일 OAuth 구현 보안 체크리스트:
인증 흐름:
□ Authorization Code Flow + PKCE 사용
□ WebView 대신 시스템 브라우저 사용 (ASWebAuthenticationSession / Custom Tab)
□ Implicit Flow 사용 안 함 (deprecated)
□ state 파라미터로 CSRF 방지
토큰 저장:
□ iOS: Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)
□ Android: EncryptedSharedPreferences
□ UserDefaults/SharedPreferences에 저장 안 함
□ 로컬 로그에 토큰 출력 안 함
토큰 관리:
□ Access Token 만료 시간 짧게 (1시간 이하)
□ Refresh Token으로 자동 갱신
□ 앱 삭제 시 Keychain 데이터 삭제 여부 확인
□ 계정 로그아웃 시 토큰 즉시 revoke
마무리
모바일 OAuth 구현의 핵심은 세 가지다.
- PKCE: code 가로채기 공격 방어, 반드시 적용
- 시스템 브라우저: WebView는 키로거 공격에 취약, ASWebAuthenticationSession/Custom Tab 사용
- Keychain/EncryptedSharedPreferences: 토큰은 반드시 암호화 저장
AppAuth 라이브러리(iOS/Android 공식 지원)를 쓰면 PKCE, state, 코드 교환을 자동으로 처리해준다. 직접 구현보다 훨씬 안전하고 검증된 방법이다.