WebView 로그인 세션을 안전하게 전달하기
WebView 로그인 세션을 안전하게 전달하기
Native 앱의 refresh token을 URL, JavaScript 전역 변수, localStorage에 복사하지 않는다. Native는 플랫폼 보안 저장소에서 장기 credential을 소유하고, 서버에서 WebView 전용·짧은 수명·좁은 권한의 session을 발급받는다. 가능하면 Secure, HttpOnly, 적절한 SameSite 속성의 cookie를 WebView cookie store에 설정해 JavaScript가 token 값을 읽지 못하게 한다. Bearer token을 bridge로 전달해야 한다면 access token만 memory에 두고 origin·top frame·만료·logout 경쟁을 함께 통제한다.
목차
- #Native 로그인과 Web 로그인을 연결할 때 생기는 위험
- #먼저 보호해야 할 자산과 공격 경로를 적기
- #장기 Credential과 Web Session을 분리하기
- #세션 전달 방식 비교
- #권장 흐름은 서버가 Web 전용 Session을 발급하는 것
- #iOS WKWebView에 Cookie를 설정한 뒤 이동하기
- #Android WebView에도 Cookie 완료 뒤 이동하기
- #Bridge로 Access Token을 전달해야 하는 경우
- #Token Refresh의 단일 소유자를 유지하기
- #401을 받았을 때 무한 재시도하지 않기
- #Logout은 양쪽 저장소를 함께 폐기하기
- #Origin과 Frame이 바뀌면 Session Capability를 닫기
- #Page Reload와 앱 Lifecycle 처리하기
- #테스트해야 할 인증 상태 행렬
- #운영 로그에 Token을 남기지 않기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Native 로그인과 Web 로그인을 연결할 때 생기는 위험
Native에서 이미 로그인했는데 WebView가 다시 로그인 화면을 보여 주면 사용자 경험이 나쁘다. 그래서 가장 빨리 떠오르는 방법은 URL query에 token을 붙이는 것이다.
// 피해야 할 예
final url = Uri.https(
'app.example.invalid',
'/web/orders',
{'access_token': accessToken},
);
await webViewController.loadRequest(url);
URL은 생각보다 많은 곳에 복제된다.
- navigation history
- 서버 access log와 reverse proxy log
- analytics와 crash report의 현재 URL
- redirect의
Referer처리 - 화면 캡처와 디버그 도구
- Web code가 읽는
location.href
전역 JavaScript 변수에 주입하는 방식도 안전하지 않다.
await controller.runJavaScript(
'window.__SESSION__ = "$accessToken";',
);
문자열 escaping 문제뿐 아니라 같은 page context의 script가 값을 읽을 수 있다. XSS가 있다면 token을 곧바로 가져간다. page reload 뒤 재주입 시점 경쟁도 생긴다.
localStorage에 넣으면 reload 문제는 줄어 보이지만 장기 노출 시간이 늘어난다.
// 피해야 할 예
localStorage.setItem("refresh_token", token);
WebView의 DOM storage와 cookie, cache는 각각 별도 subsystem이다. clearCache() 하나로 로그인 흔적이 모두 지워진다고 가정할 수 없다.
Native의 로그인 상태를 그대로 복제하는 것이 아니라, WebView가 필요한 동안만 사용할 별도 session을 안전하게 위임한다.
이 글의 도메인과 token은 모두 가상의 예시이며 실제 서비스 credential이나 코드가 아니다.
먼저 보호해야 할 자산과 공격 경로를 적기
세션 전달 방식을 고르기 전에 threat model을 간단히 적는다.
| 자산 | 탈취됐을 때 영향 | 노출 경로 |
|---|---|---|
| Native refresh token | 장기간 access token 재발급 | bridge, Web storage, 로그 |
| Web access token | 만료 전 API 호출 | XSS, JavaScript memory |
| Web session cookie | Web session 탈취 | cookie store, network |
| 사용자 식별 정보 | privacy 침해 | URL, telemetry, page source |
| logout 상태 | 이전 session 재사용 | 양쪽 저장소 불일치 |
공격 가능성도 구체화한다.
- 신뢰한 Web content에 XSS가 발생한다.
- WebView가 외부 origin이나 iframe을 로드한다.
- debug log가 production에 남는다.
- Native와 Web이 동시에 refresh한다.
- logout 도중 in-flight 요청이 새 token을 저장한다.
- page reload 뒤 이전 bridge response가 도착한다.
이 목록을 보면 refresh token을 Web에 넘기지 않는 것이 첫 번째 경계가 된다. access token도 가능하면 JavaScript가 읽을 수 없는 cookie 기반 session으로 바꾼다.
장기 Credential과 Web Session을 분리하기
인증 값을 수명과 권한으로 나눈다.
flowchart LR
A[Native refresh credential] -->|보안 저장소| B[Native auth repository]
B -->|인증된 요청| C[Session handoff API]
C --> D[짧은 Web session]
D --> E[WebView cookie store]
E --> F[Web API]| 값 | 소유자 | 수명 | 권한 | 저장 위치 |
|---|---|---|---|---|
| Refresh credential | Native auth repository | 상대적으로 김 | token 재발급 | Keychain·secure storage |
| Native access token | Native API client | 짧음 | Native API scope | memory 중심 |
| Web session | WebView HTTP layer | 짧음 | Web 기능 scope | HttpOnly cookie 우선 |
| CSRF token | Web app | session 연계 | 요청 위조 방지 | 정책에 맞는 Web state |
Web session은 Native session과 다른 식별자와 만료를 가져야 한다. 하나가 폐기돼도 다른 session을 추적해 revoke할 수 있도록 서버가 부모-자식 관계를 기록할 수 있다.
native_session_id: ns_demo_4
web_session_id: ws_demo_9
audience: embedded-web
scope: orders:read orders:write
expires_in: 10 minutes
parent: ns_demo_4
WebView가 필요한 권한만 scope로 제한한다. Native refresh token과 같은 bearer value를 두 환경에서 공유하면 어느 쪽 침해도 전체 session 침해가 된다.
세션 전달 방식 비교
| 방식 | JavaScript 노출 | 장점 | 주요 위험 |
|---|---|---|---|
| URL query token | 높음 | 구현이 단순해 보임 | URL·로그·history 유출 |
| JS 전역 변수 주입 | 높음 | page에서 즉시 사용 | XSS·escaping·재주입 경쟁 |
localStorage bearer |
높음·지속 | reload 후 유지 | XSS와 logout 잔존 |
| Bridge로 단기 access token | 높음·memory | API client 재사용 | XSS 시 탈취, refresh 경계 필요 |
| Native가 Web session cookie 설정 | HttpOnly면 낮음 |
브라우저 HTTP 흐름 활용 | cookie 완료·CSRF·logout 관리 |
| Web 내부 OAuth login | Web 정책에 따름 | 표준 browser flow | 사용자 재로그인 UX |
이 글의 기본 선택은 WebView 전용 session cookie다. 다만 cookie라고 자동으로 안전한 것은 아니다.
Secure로 HTTPS에서만 전송HttpOnly로 JavaScript 읽기 차단- host와 path 최소화
- 필요한
SameSite정책 - 짧은 expiry
- 서버측 revoke
- state-changing 요청의 CSRF 방어
cookie 속성은 앱이 임의로 고르기보다 Web backend의 session 정책과 함께 설계한다.
권장 흐름은 서버가 Web 전용 Session을 발급하는 것
Native가 refresh credential로 Web session handoff API를 호출한다.
POST /v1/embedded-web/sessions HTTP/1.1
Authorization: Bearer native-access-token
Content-Type: application/json
{
"audience": "embedded-web",
"requestedPath": "/orders",
"deviceSessionId": "device-session-demo"
}
서버는 WebView 전용 cookie material과 만료, 허용 시작 URL을 반환한다. 실제 API에서는 cookie value가 telemetry에 남지 않도록 response body와 logging 정책을 별도로 확인한다.
{
"session": {
"name": "__Host-embedded_session",
"value": "opaque-demo-value",
"expiresAt": "2026-01-06T03:10:00Z",
"secure": true,
"httpOnly": true,
"sameSite": "Lax"
},
"startUrl": "https://app.example.invalid/orders"
}
전체 흐름은 다음과 같다.
sequenceDiagram
participant UI as Native UI
participant Auth as Native Auth
participant API as Session API
participant Store as WebView Cookie Store
participant Web as Web App
UI->>Auth: Hybrid 화면 열기
Auth->>API: Web session 발급
API-->>Auth: 짧은 cookie + start URL
Auth->>Auth: host·expiry·attribute 검증
Auth->>Store: cookie 설정
Store-->>Auth: 설정 완료
Auth->>Web: 허용된 start URL load
Web->>API: cookie가 포함된 요청중요한 순서는 cookie 설정 완료 뒤 navigation이다. 비동기 cookie 저장이 끝나기 전에 URL을 로드하면 첫 요청만 anonymous가 되어 login redirect가 깜빡이거나 잘못 cache될 수 있다.
iOS WKWebView에 Cookie를 설정한 뒤 이동하기
WKHTTPCookieStore는 특정 WebView의 HTTP cookie를 관리한다. WebView configuration과 같은 data store의 cookie store를 사용해야 한다.
struct EmbeddedWebSession {
let value: String
let expiresAt: Date
let startURL: URL
}
enum WebSessionBootstrapError: Error {
case invalidStartURL
case invalidCookie
}
허용 host를 확인하고 HTTPCookie를 만든다.
func makeSessionCookie(
session: EmbeddedWebSession
) throws -> HTTPCookie {
guard
session.startURL.scheme == "https",
session.startURL.host == "app.example.invalid"
else {
throw WebSessionBootstrapError.invalidStartURL
}
let properties: [HTTPCookiePropertyKey: Any] = [
.name: "__Host-embedded_session",
.value: session.value,
.domain: "app.example.invalid",
.path: "/",
.secure: "TRUE",
.expires: session.expiresAt
]
guard let cookie = HTTPCookie(properties: properties) else {
throw WebSessionBootstrapError.invalidCookie
}
return cookie
}
HttpOnly와 SameSite 속성의 생성·지원 방식은 대상 OS와 사용하는 API에서 실제로 검증한다. cookie 이름 규칙을 사용한다면 domain attribute 등 해당 규칙의 요구사항과 생성 결과가 일치하는지 확인해야 한다.
cookie 설정 완료 뒤 load한다.
@MainActor
func bootstrap(
webView: WKWebView,
session: EmbeddedWebSession
) async throws {
let cookie = try makeSessionCookie(session: session)
let store = webView.configuration
.websiteDataStore
.httpCookieStore
await withCheckedContinuation { continuation in
store.setCookie(cookie) {
continuation.resume()
}
}
webView.load(URLRequest(url: session.startURL))
}
별도의 WKWebsiteDataStore를 configuration에 넣었다면 cookie도 그 store에 넣는다. 다른 store를 수정하고 WebView가 다른 store를 사용하면 설정 성공처럼 보여도 요청에는 cookie가 없다.
Android WebView에도 Cookie 완료 뒤 이동하기
Android에서는 CookieManager가 WebView cookie를 관리한다. setCookie callback 결과를 확인하고 navigation한다.
data class EmbeddedWebSession(
val cookieValue: String,
val maxAgeSeconds: Long,
val startUrl: String,
)
fun bootstrapWebView(
webView: WebView,
session: EmbeddedWebSession,
onFailure: (WebSessionError) -> Unit,
) {
val uri = Uri.parse(session.startUrl)
if (uri.scheme != "https" ||
uri.host != "app.example.invalid"
) {
onFailure(WebSessionError.InvalidStartUrl)
return
}
val cookie = buildString {
append("__Host-embedded_session=")
append(session.cookieValue)
append("; Path=/")
append("; Max-Age=${session.maxAgeSeconds}")
append("; Secure")
append("; HttpOnly")
append("; SameSite=Lax")
}
CookieManager.getInstance().setCookie(
"https://app.example.invalid",
cookie,
) { success ->
if (success) {
webView.loadUrl(session.startUrl)
} else {
onFailure(WebSessionError.CookieRejected)
}
}
}
cookie value에 header 구분 문자가 들어가지 않는 opaque server-generated format을 사용하고, 원문을 로그로 출력하지 않는다. 앱이 필요한 third-party cookie 정책도 별도로 검토한다. 인증을 위해 무조건 third-party cookie 전체를 허용하는 식으로 해결하지 않는다.
Bridge로 Access Token을 전달해야 하는 경우
Web application이 bearer token API client로 이미 구성돼 있고 cookie session endpoint를 만들기 어려울 수 있다. 이 경우에도 refresh token은 Native에 남긴다. Web은 신뢰한 top frame에서 짧은 access token을 요청하고 memory에만 보관한다.
type AccessSession = {
accessToken: string;
expiresAtEpochMs: number;
audience: "embedded-web";
};
class InMemorySession {
private current: AccessSession | null = null;
set(session: AccessSession): void {
this.current = session;
}
clear(): void {
this.current = null;
}
getUsable(now: number): AccessSession | null {
if (!this.current) return null;
if (this.current.expiresAtEpochMs - now < 30_000) return null;
return this.current;
}
}
session 요청은 WebView 브릿지를 버전 있는 프로토콜로 만들기의 envelope을 사용한다.
const response = await bridge.request<AccessSession>(
"AUTH_SESSION_REQUEST",
{
audience: "embedded-web",
minValiditySeconds: 30,
},
);
sessionStore.set(validateAccessSession(response));
Native는 다음을 검증한다.
- 현재 top-level origin이 정확히 허용됐는가
- 현재 bridge session ID인가
- WebView가 인증 화면용 instance인가
- 요청 audience와 scope가 allowlist 안인가
- Native session이 logout 중이 아닌가
Web은 token을 localStorage, IndexedDB, cookie via JavaScript, Redux persistence에 저장하지 않는다. page reload 시 다시 요청한다. 이 방식은 XSS가 실행 중인 동안 access token을 훔칠 위험을 없애지는 못한다. 그래서 수명을 짧게 하고 server-side authorization과 CSP를 함께 적용한다.
HttpOnly cookie는 JavaScript가 값을 직접 읽지 못하게 한다. Bridge로 반환한 bearer token은 그 순간 JavaScript memory에 존재한다. 두 방식의 공격 표면은 같지 않다.
Token Refresh의 단일 소유자를 유지하기
Native와 Web이 같은 refresh token으로 각각 갱신하면 rotation 경쟁이 발생한다.
sequenceDiagram
participant Web
participant Native
participant Server
Web->>Server: refresh token R1 사용
Native->>Server: refresh token R1 사용
Server-->>Web: R2 발급, R1 폐기
Server-->>Native: reuse 감지 또는 실패
Native-->>Web: session invalid eventrefresh는 Native auth repository 하나만 수행한다. Web이 새 access session을 요청하면 Native가 현재 token을 확인하고 필요하면 single-flight refresh를 실행한다.
Future<AccessSession> getWebAccessSession() {
final usable = cache.currentWebSession;
if (usable != null && !usable.expiresSoon(clock.now())) {
return Future.value(usable);
}
return _refreshInFlight ??= _refreshWebSession()
.whenComplete(() => _refreshInFlight = null);
}
동시에 요청한 여러 Web API가 모두 refresh bridge를 호출해도 Native network 요청은 하나만 실행된다. 성공 결과는 현재 Native auth generation과 연결한다.
401을 받았을 때 무한 재시도하지 않기
Web API client는 401 하나마다 refresh와 재시도를 무한 반복하지 않는다.
async function authorizedFetch(
input: RequestInfo,
init: RequestInit = {},
): Promise<Response> {
const firstSession = await sessionProvider.get();
const first = await fetchWithSession(input, init, firstSession);
if (first.status !== 401) {
return first;
}
sessionProvider.invalidate(firstSession);
const refreshed = await sessionProvider.get();
return fetchWithSession(input, init, refreshed);
}
재시도는 한 번으로 제한하고, 쓰기 요청은 idempotency를 검토한다. 첫 요청이 서버에서 처리됐지만 응답만 401이나 network error로 보인 특수 상황이라면 중복 실행이 위험하다.
새 session 요청도 실패하면 Web은 authenticated route를 닫고 Native에 상태 확인을 요청한다. 자체적으로 login 화면을 띄울지 Native login 화면으로 나갈지는 하나의 navigation 정책으로 정한다.
Logout은 양쪽 저장소를 함께 폐기하기
logout은 Native Keychain만 지우거나 Web cookie만 지우는 것으로 끝나지 않는다.
sequenceDiagram
participant User
participant Native
participant Server
participant Cookie as Web Cookie Store
participant Web
User->>Native: logout
Native->>Native: auth generation 증가
Native->>Server: native/web session revoke
Native->>Cookie: session cookie 삭제
Native->>Web: SESSION_REVOKED event
Web->>Web: memory session·query cache 제거
Native->>Native: Keychain credential 삭제실제로는 network revoke가 실패할 수 있으므로 local logout을 막지 않되 retry 정책을 둔다. 먼저 auth generation을 증가시키면 logout 전에 시작한 refresh 결과가 늦게 도착해도 새 credential로 저장하지 못하게 할 수 있다.
Future<void> logout() async {
final logoutGeneration = authState.beginLogout();
unawaited(
sessionApi.revokeAll().catchError(logRevokeFailure),
);
await Future.wait([
credentialStore.clear(),
webSessionStore.clearCookies(),
webViewState.clearSiteData(),
]);
authState.finishLogout(logoutGeneration);
}
어떤 Web data를 지울지 명시한다. cookie 삭제, memory state, Web storage, HTTP cache는 서로 다른 저장 영역이다. 앱 안에 여러 Web 계정을 지원한다면 모든 site data 삭제가 다른 계정이나 비인증 설정까지 없애는지도 검토한다.
Origin과 Frame이 바뀌면 Session Capability를 닫기
허용한 Web origin에서 외부 결제·도움말 페이지로 이동할 수 있다. WebView instance가 같다는 이유로 bridge의 session capability를 계속 노출하지 않는다.
https://app.example.invalid/orders 허용
https://help.example.invalid/article 인증 bridge 비활성
https://external.invalid/ 외부 browser
Native는 navigation commit 시 현재 top-level origin과 bridge session을 갱신한다. iframe에서 들어온 AUTH_SESSION_REQUEST는 거절한다. allowlist는 문자열 포함 검사가 아니라 URL parser의 scheme, host, port를 사용한다.
Web server도 CORS와 server-side authorization을 적용한다. client가 보낸 role, account ID, isNativeApp flag를 권한 근거로 믿지 않는다.
Page Reload와 앱 Lifecycle 처리하기
page reload가 발생하면 JavaScript memory session은 사라진다. bridge handshake를 다시 하고 새 session ID로 access session을 요청한다. 이전 page의 늦은 response는 폐기한다.
앱이 background로 갔다가 돌아오면 무조건 새 token을 주입하지 않는다.
bridgeEvents.on("APP_LIFECYCLE_CHANGED", async (event) => {
if (event.payload.state !== "resumed") return;
if (sessionStore.getUsable(Date.now())) return;
try {
sessionStore.set(await sessionProvider.requestFresh());
} catch {
router.replace("/session-required");
}
});
background에 있는 동안 Web session이 revoke됐을 수 있으므로 resume은 재검사 trigger일 뿐 “여전히 로그인”이라는 증거가 아니다. 앱 process가 종료돼도 cookie가 남을지, 매번 bootstrap할지는 WebView data store와 제품 정책으로 정한다.
테스트해야 할 인증 상태 행렬
정상 login 한 번으로는 세션 경계를 검증할 수 없다.
| 상황 | 기대 결과 |
|---|---|
| cookie 설정 성공 뒤 첫 navigation | 첫 요청부터 authenticated |
| cookie 설정 실패 | WebView load 중단, 명시적 오류 |
| access session 만료 30초 전 | Native single-flight 갱신 |
| 동시 401 다섯 개 | refresh 한 번, 각 요청 최대 한 번 재시도 |
| Web page reload | 새 session ID, 이전 response 폐기 |
| 외부 origin 이동 | session bridge 비활성 |
| iframe의 session 요청 | 거절 |
| logout 중 refresh 완료 | generation 불일치로 저장 폐기 |
| server revoke 실패 | local logout 완료, revoke 재시도 |
| 기기 잠금 중 Keychain 불가 | stale UI 또는 Native login 안내 |
| 앱 계정 전환 | 이전 cookie·Web cache 제거 |
보안 테스트에는 XSS를 가정한 script가 refresh token에 접근할 수 없는지, cookie가 JavaScript에서 보이지 않는지, bridge access token이 persistent storage에 남지 않는지도 포함한다.
운영 로그에 Token을 남기지 않기
관측에는 token 값이 필요하지 않다.
web_session_issue result=success ttl_seconds=600
web_cookie_set platform=ios result=success
web_session_refresh result=success shared_waiters=4
web_session_request result=denied reason=origin_mismatch
logout_cleanup cookie=success keychain=success revoke=pending
기록하지 않을 값:
Authorizationheader- cookie value와
Set-Cookie원문 - URL query 전체
- Keychain item data
- Web bridge response 원문
session을 연결해야 한다면 server가 발급한 비가역·비식별 correlation ID를 별도로 사용한다. token 일부를 잘라 ID로 쓰는 방식도 피한다.
구현 체크리스트
마무리
WebView 로그인 연결은 Native token을 Web에 복사하는 작업이 아니다. 서로 다른 실행 환경에 어느 정도의 권한을 얼마나 오래 위임할지 정하는 session 설계다.
Native refresh credential은 Keychain 같은 플랫폼 보안 저장소에 남긴다. 서버는 이를 근거로 WebView 전용의 짧고 제한된 session을 발급한다. 가능하면 WebView HTTP cookie store에 HttpOnly session을 설정해 JavaScript가 값에 직접 접근하지 못하게 하고, 설정 완료 뒤 허용된 HTTPS URL을 연다.
기존 Web API 구조 때문에 bearer token을 bridge로 전달한다면 refresh token은 제외하고 access token만 memory에서 짧게 사용한다. origin·top frame·page session을 검증하고 refresh는 Native 한곳에서만 수행한다. logout은 Keychain, cookie, Web storage, in-flight refresh를 하나의 상태 전환으로 처리한다.
안전한 handoff의 목표는 token을 더 복잡하게 숨기는 것이 아니다. 침해 가능한 한 경계가 가진 권한과 수명을 줄이고, 폐기 시점에 모든 복제본이 함께 사라지게 만드는 것이다.
관련 노트
- Access Token과 Refresh Token의 역할 분리
- Refresh Token Rotation으로 재사용 공격 줄이기
- App Group UserDefaults와 Keychain의 역할 차이
- WebView와 Native의 책임 경계
- WebView 브릿지를 버전 있는 프로토콜로 만들기
- 모바일 앱에서 Access Token을 저장하는 위치
- 재시도 가능한 API에 Idempotency-Key 적용하기