WinForms/WPF 앱에 Entra ID 인증을 넣는 방법 ── MSAL.NET과 WAM 브로커의 실무 구성

· 업데이트: · · Windows, C#, .NET, WinForms, WPF, Entra ID, 인증, 보안, 기술상담

수정 이력(8건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 앞머리에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
Silent에서 Interactive로 내려갈 때의 토큰 획득 흐름을 시퀀스 그림으로 만들고, 도입 여부 판단표에 판정 순서를 나타내는 그림을 곁들였습니다. 더불어 5장에 WAM 브로커와 시스템 브라우저에서 MSAL이 받는 것이 다르다(브로커는 토큰 획득까지 끝낸 뒤 돌려주고, 브라우저는 인가 코드를 돌려준다)는 점을 보여주는 그림을 추가했습니다. 본문 설명은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
로그인할 계정을 `GetAccountsAsync()`의 `FirstOrDefault()`로 고르던 구현을 고쳤습니다. 계정을 전환한 뒤나 공유 단말에서는 캐시에 여러 계정이 남습니다. 열거 순서에는 의미가 없으므로, 이는 「우연히 맨 앞에 있던 사람」을 조용히 고르는 구현입니다. 게다가 그 계정의 토큰이 살아 있으면 `AcquireTokenSilent`이 성공하고 picker도 열리지 않아, 다른 사람의 Graph 데이터를 표시·갱신해도 아무도 알아채지 못합니다. 이전에 고른 계정의 `HomeAccountId`를 저장해 대조하고, 후보를 하나로 정할 수 없을 때는 대화형으로 사용자에게 고르게 했습니다.
앞머리에 약어 표(MSAL, SSO, MFA, FIDO, JWT, ROPC, WAM, UPN)와 대상 독자·전제 환경 표를 추가했습니다. 브로커를 우선하면서 브라우저로 전환하는 통합 예를 새로 두고, `WithRedirectUri`와 `WithDefaultRedirectUri()`의 쓰임새를 표로 정리했습니다. 관리 센터 작업은 「여는 화면 이름과, 그 화면에서 누르는 것」의 빠른 참조 표로 만들었습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635366)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「WinForms/WPF 앱에 Entra ID 인증을 넣는 방법 ── MSAL.NET과 WAM 브로커의 실무 구성」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/winforms-wpf-entra-id-auth/

DOI(등록된 아카이브)
10.5281/zenodo.21635366
DOI(마지막 등록 버전)
10.5281/zenodo.21635367

「사내 업무 앱마다 로그인 화면을 만들고, 비밀번호를 자체 데이터베이스로 관리하고 있다. 퇴사자가 나올 때마다 앱마다 계정을 정지하고 다니는 일이 힘들다」「Microsoft 365는 전사에 넣었으니, 그 계정으로 그대로 로그인하게 할 수 없을까」. 데스크톱 앱 수정 상담에서, 최근 몇 년 꾸준히 늘고 있는 주제입니다. 비밀번호 유출 사고나 제로 트러스트 대응 맥락에서 정보시스템 부서로부터 「자체 비밀번호 관리를 그만둬 달라」는 요청이 오는 형태로 나타나기도 합니다.

결론부터 말하면, Microsoft 365를 쓰는 조직이라면 WinForms / WPF 사내 앱의 로그인을 Entra ID(구 Azure AD) 로 맡기는 일은 타당한 투자입니다. 앱은 비밀번호를 전혀 맡지 않게 되고, 다요소 인증(MFA)·조건부 액세스·로그인 로그 같은 테넌트 쪽 방어와 감사가 그대로 사내 앱에도 적용됩니다. 구현도 MSAL.NET이라는 라이브러리와 수십 줄의 코드로 끝나는 범위입니다.

다만 데스크톱 앱 특유의 함정이 몇 가지 있습니다. 「사용자 이름과 비밀번호를 텍스트 상자로 받아 뒤에서 인증하는」 예전 방식(ROPC)은 공식적으로 폐지 방향이며, 새로 채택해서는 안 됩니다. 토큰 캐시를 영속화하지 않으면 시작할 때마다 로그인 화면이 나오고, Windows에서는 브로커(WAM)를 쓸지 여부에 따라 경험과 보안이 크게 달라집니다. 이 기사에서는 개념의 최소한 정리부터 앱 등록, MSAL.NET 구현, WAM, 캐시, 도입 판단, 운영상의 함정까지 한 바퀴 정리합니다.

이 기사의 대상 독자와 전제 환경

항목 내용
대상 독자 기존 또는 신규 WinForms / WPF 업무 앱에 Microsoft 365 계정으로의 로그인을 넣는 입장인 개발자
테넌트 쪽 전제 Microsoft 365 / Entra ID를 도입한 상태일 것. 앱 등록과 관리자 동의(3장)는 테넌트 쪽 작업이므로, 직접 조작할 수 없으면 3장을 그대로 정보시스템 부서에 대한 의뢰서로 쓰세요
실행 환경 Windows. WAM 브로커(5장)를 쓰려면 Windows 10(1703) 이후 / Windows Server 2019 이후. 그 이전이거나 Mac·Linux에서는 브라우저로 자동 fallback합니다1
.NET .NET Framework 4.6.2 이후, 또는 .NET 6 이후. 대화형 인증에 쓰는 브라우저 기본값이 프레임워크마다 다릅니다(4장)2
NuGet 패키지 Microsoft.Identity.Client(필수), Microsoft.Identity.Client.Broker(WAM용. MSAL.NET 4.52.0 이후)1, Microsoft.Identity.Client.Extensions.Msal(토큰 캐시 영속화)3
네트워크 최초 로그인과 토큰 갱신에 Entra ID로의 도달이 필요합니다. 완전 오프라인 환경에서는 성립하지 않습니다(8장)

이 기사에서 쓰는 약어

약어 정식 이름 이 기사에서의 의미
MSAL Microsoft Authentication Library Microsoft가 제공하는 인증 라이브러리. .NET 판이 MSAL.NET(NuGet의 Microsoft.Identity.Client)
SSO Single Sign-On(싱글 사인온) 한 번 로그인하면 다른 앱에서 다시 로그인하지 않아도 되는 상태
MFA Multi-Factor Authentication(다요소 인증) 비밀번호에 더해 스마트폰 앱이나 생체 인증 등 다른 요소도 요구하는 인증
FIDO Fast IDentity Online 비밀번호를 쓰지 않는 인증의 표준 규격. 이 기사에서는 USB형 등의 보안 키를 가리킵니다
JWT JSON Web Token 서명이 붙은 JSON으로 클레임(이용자 속성)을 나르는 토큰 형식. ID 토큰·액세스 토큰의 실체
ROPC Resource Owner Password Credentials 사용자 이름과 비밀번호를 앱이 직접 받아 인증하는 흐름. 폐지 방향(2.3절)
WAM Web Account Manager Windows에 들어 있는 인증 브로커(5장)
UPN User Principal Name taro@example.co.jp 형식의 로그인 이름. 성 변경 등으로 바뀔 수 있습니다(7.1절)

1. 먼저 결론

  • ID와 비밀번호의 자체 관리를 그만두고 Entra ID로 모으면, 비밀번호 보관·재설정 대응·퇴사자 계정 정지·로그인 감사가 전부 테넌트 쪽 일이 됩니다. 앱 쪽 책임 범위가 극적으로 작아지는 것이 가장 큰 이점입니다.
  • 데스크톱 앱은 퍼블릭 클라이언트입니다. exe는 배포처에서 분석할 수 있으므로 클라이언트 시크릿을 가질 수 없습니다(가지게 해서는 안 됩니다). 앱 등록도 퍼블릭 클라이언트로 구성합니다.4
  • 사용자 이름과 비밀번호를 앱이 직접 맡는 ROPC(Resource Owner Password Credentials)는 공식 문서에 「폐지(deprecated)」라고 명시되어 있고, 마이그레이션 가이드가 나와 있습니다. MFA·조건부 액세스와 양립하지 않으며, 실질적으로 쓸 수 없게 되는 방향입니다. 신규 채택은 금지로 생각하세요.56
  • 구현은 MSAL.NET(Microsoft.Identity.Client) 이며, 먼저 AcquireTokenSilent, MsalUiRequiredException이 나오면 AcquireTokenInteractive 라는 호출 패턴이 유일한 기본형입니다.7
  • Windows에서는 WAM(Web Account Manager) 브로커를 통한 인증이 권장입니다. Windows에 이미 로그인한 계정과의 SSO, 조건부 액세스·Windows Hello·FIDO 키 대응, 리프레시 토큰의 디바이스 바인딩이 WithBroker 한 줄로 따라옵니다.1
  • 토큰 캐시 영속화를 빠뜨리면 앱을 다시 시작할 때마다 로그인 화면이 나옵니다. Microsoft.Identity.Client.Extensions.Msal의 암호화 캐시를 처음부터 넣으세요.3
  • Entra 인증은 네트워크가 전제인 구조입니다. 완전 오프라인으로 돌아가야 하는 현장 앱에서는 성립하지 않으므로, 8장의 판단표로 도입 여부를 먼저 가려 보세요.

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 25건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 전체 그림 ── 자체 비밀번호 관리를 그만둔다는 것은 무엇인가

2.1 자체 관리의 무엇이 문제인가

업무 앱이 자체 사용자 테이블로 비밀번호를 관리하고 있으면, 다음 책임이 모두 앱(곧 개발한 우리)에게 집니다.

  • 보관: 해시 방식의 선정과 구현(솔트 없는 MD5 그대로 방치된 15년짜리 테이블을 지금도 봅니다)
  • 운영: 비밀번호 재설정 문의 대응, 잠금, 초기 비밀번호 배포
  • 라이프사이클: 퇴사·이동 시 계정 정지. 앱이 5개면 5번 정지하러 다닌다
  • 감사: 누가 언제 로그인했는지의 기록과 보존. 다요소 인증은 사실상 구현 불가능

Entra ID에 인증을 위임하면 이 네 가지가 앱 코드에서 사라지고, 테넌트 관리로 한곳에 모입니다. 퇴사자는 Entra ID 계정을 비활성화하면 모든 앱에서 즉시 로그인할 수 없고, 로그인 로그도 자동으로 남습니다. Microsoft 365를 이미 도입한 조직이 자체 인증을 계속할 이유는 거의 없습니다. 참고로 Google Workspace 조직에서 Windows 로그온 자체를 Google 계정으로 모으는 짝이 되는 구조는 「GCPW란」에서 썼습니다.

2.2 최소한의 개념 ── 퍼블릭 클라이언트와 토큰

OAuth 2.0 / OpenID Connect의 교과서적 설명은 생략하고, 데스크톱 앱 구현에 필요한 개념만 늘어놓습니다.

개념 데스크톱 앱에서의 의미
퍼블릭 클라이언트 exe·모바일 앱처럼 비밀(클라이언트 시크릿)을 안전하게 유지할 수 없는 앱. 사용자의 대리로만 토큰을 얻을 수 있다
기밀 클라이언트(confidential client) Web 서버나 데몬처럼 시크릿이나 인증서를 유지할 수 있는 앱. 데스크톱 앱은 이쪽이 아니다
ID 토큰 「이 사람이 누구인지」를 나타내는 JWT(JSON Web Token). 로그인 기능만 필요하면 이것으로 충분하다
액세스 토큰 특정 API(Microsoft Graph나 자사 Web API)를 호출하기 위한 통행증. 대상(audience)과 스코프가 들어 있다
리프레시 토큰 위 두 가지를 대화 없이 갱신하기 위한 토큰. MSAL이 캐시 안에서 자동 관리하며, 앱에서 직접은 보이지 않는다

중요한 것은 첫 줄입니다. exe는 배포처에서 분석·역컴파일할 수 있으므로, 심어 둔 「비밀」은 비밀이 되지 않습니다. 그래서 시크릿 없이 동작하는 퍼블릭 클라이언트로 등록하고, 인증 자체(비밀번호 입력이나 MFA)는 브라우저 또는 OS의 브로커에 맡기고, 앱은 토큰만 받는 설계가 됩니다. 앱이 사용자 비밀번호에 닿지 않는다는 것 자체가 이 구조의 근간입니다.

2.3 ROPC는 끝난 방식 ── 근거를 확인한 결과

예전 발상이면 「자체 로그인 화면에서 사용자 이름과 비밀번호를 받아, 뒤에서 Entra ID에 검증받으면 된다」로 흐르기 쉽습니다. 이것이 ROPC(사용자 이름·비밀번호 직접 전달)이며, MSAL.NET에도 AcquireTokenByUsernamePassword로 남아 있기는 하지만, 공식 문서의 현재 서술은 명확합니다.

  • 퍼블릭 클라이언트용 ROPC는 「보안 위험이므로 폐지(deprecated)되었다」고 명시되어 있고, 더 안전한 흐름으로의 마이그레이션 가이드가 공개되어 있습니다.6
  • ROPC는 MFA·조건부 액세스와 호환되지 않습니다. 테넌트에서 MFA가 필수가 된 사용자는 이 흐름에서는 차단되어 로그인할 수 없습니다.5
  • SSO가 동작하지 않고, 개인 Microsoft 계정도 쓸 수 없으며, 비밀번호 없는(FIDO, Authenticator) 계정도 로그인할 수 없습니다.5
  • Microsoft의 Web API 쪽에서도 MFA를 마친 토큰만 받는 움직임이 진행 중이며, 공식 문서 자신이 「ROPC에 의존하는 앱은 막힌다(locked out). 데스크톱 앱은 브로커 기반 인증으로 이전하라」고 적고 있습니다.5

MFA 필수는 테넌트 쪽 설정으로 언제든 일어나므로, 「지금은 돌아가니까」라는 이유로 ROPC를 채택하면 어느 날 갑자기 전원이 로그인하지 못하게 됩니다. 기존 앱이 ROPC로 돌아가고 있어도 이전을 전제로 계획을 세우세요. 데스크톱 앱에서 써도 되는 획득 방법은 실질적으로 다음 두 가지입니다.

흐름 쓰는 곳
대화형(브로커 / 브라우저) 일반적인 GUI 앱. 주력
디바이스 코드 흐름 브라우저를 표시할 수 없는 환경(SSH 쪽 콘솔 등). URL과 코드를 표시하고, 다른 디바이스의 브라우저로 로그인하게 한다

3. 앱 등록 ── Entra 관리 센터에서의 설정

코드를 쓰기 전에 테넌트에 앱을 등록합니다. 개발자가 직접 할 수 없으면 이 절의 내용을 그대로 정보시스템 부서에 대한 의뢰서로 쓰세요.

관리 센터 화면 구성은 개정이 들어오기도 하므로, 이 기사에서는 메뉴의 겉모습이 아니라 도착하는 화면 이름과, 그 화면에서 누르는 것으로 적습니다. 전체 작업을 먼저 목록으로 둡니다.

목적 여는 화면 그 화면에서의 조작
앱을 등록한다 「앱 등록」 「새로 등록」→ 이름과 「지원되는 계정 종류」를 지정(3.1절)
ID를 적어 둔다 등록한 앱의 「개요」 「애플리케이션(클라이언트) ID」와 「디렉터리(테넌트) ID」를 복사
리디렉트 URI를 더한다 등록한 앱의 「인증」 「플랫폼 추가」→ 「모바일 애플리케이션 및 데스크톱 애플리케이션」→ URI를 입력(3.2절 표의 세 가지)
사용 권한을 더한다 등록한 앱의 「API 사용 권한」 「사용 권한 추가」→ 「Microsoft Graph」→ 「위임된 사용 권한」→ User.Read(3.3절)
관리자 동의를 부여한다 등록한 앱의 「API 사용 권한」 「(테넌트 이름)에 관리자 동의 부여」를 실행(3.3절)
퍼블릭 클라이언트 허용 등록한 앱의 「인증」 「고급 설정」의 「퍼블릭 클라이언트 흐름 허용」(3.4절)

3.1 등록 본체

Microsoft Entra 관리 센터(entra.microsoft.com)의 [앱 등록] → [새로 등록] 에서 만듭니다.8

  • 이름: 동의 화면이나 로그인 로그에 나오므로 「재고 관리 시스템」처럼 업무 쪽에서 통하는 이름으로 합니다.
  • 지원되는 계정 종류: 사내 앱이면 「이 조직 디렉터리만의 계정」(싱글 테넌트) 한 가지입니다. 멀티 테넌트는 여러 조직에 배포하는 제품일 때만입니다.
  • 등록 후 표시되는 애플리케이션(클라이언트) ID와 디렉터리(테넌트) ID를 적어 두고, 앱 설정에 넣습니다(둘 다 비밀 정보가 아닙니다).

3.2 리디렉트 URI ── 플랫폼은 「모바일 애플리케이션 및 데스크톱 애플리케이션」

인증 후 토큰을 받는 장소의 선언입니다. [인증] → [플랫폼 추가] → [모바일 애플리케이션 및 데스크톱 애플리케이션] 을 고르고, 인증 방식에 맞는 URI를 등록합니다.4

인증 방식 등록하는 리디렉트 URI
WAM 브로커(주력, 5장) ms-appx-web://microsoft.aad.brokerplugin/{클라이언트ID}
시스템 브라우저 http://localhost
내장 브라우저 https://login.microsoftonline.com/common/oauth2/nativeclient

WAM용 ms-appx-web://... 는 MSAL 쪽 코드에는 쓰지 않지만, 앱 등록 쪽에는 필수입니다.9 WAM을 쓸 수 없는 환경에서의 브라우저 fallback(5장)을 생각하면, 표의 세 가지를 처음부터 전부 등록해 두는 편이 실무적입니다. 특히 주의할 것이 WithDefaultRedirectUri()의 동작이며, 해석 대상은 플랫폼에 따라 다릅니다. .NET Framework에서는 https://login.microsoftonline.com/common/oauth2/nativeclient, .NET(Core 이후)에서는 http://localhost로 해석됩니다.10 ms-appx-web과 http://localhost만 등록한 .NET Framework 앱이 WAM에서 브라우저로 fallback하면, nativeclient와의 불일치로 인증 오류가 납니다. 세 가지 모두 등록하거나, WithRedirectUri(...)로 명시적으로 고정하세요. 「Web」플랫폼 쪽에 등록해 버려 인증 오류가 나는 것도 전형적인 막힘입니다.

3.3 API 사용 권한과 관리자 동의

[API 사용 권한] 에서 앱이 호출하는 API의 위임된 사용 권한(delegated permission) 을 추가합니다. 로그인과 프로필 표시만이면 기본으로 부여된 Microsoft Graph의 User.Read 로 충분합니다.

추가한 뒤 [(테넌트 이름)에 관리자 동의 부여] 를 실행해 달라고 합니다.8 이렇게 하면 최초 로그인 때 사용자별 동의 대화 상자가 나오지 않습니다. 사용자 본인 동의가 비활성화된 테넌트에서는 관리자 동의 없이는 최초 로그인이 「관리자 승인이 필요합니다」에서 멈추므로, 사내 배포 앱에서는 배포 전에 관리자 동의까지 끝내 두는 것이 원칙입니다.

3.4 「퍼블릭 클라이언트 흐름 허용」플래그

[인증]의 상세 설정에 있는 「퍼블릭 클라이언트 흐름 허용」은 디바이스 코드 흐름이나 통합 Windows 인증처럼 리디렉트 URI를 쓰지 않는 흐름을 쓸 때 「예」로 합니다.4 대화형(브라우저 / 브로커)만이면 필수는 아닙니다. 참고로 이 앱 등록에는 클라이언트 시크릿도 인증서도 만들지 않습니다. 「인증서 및 비밀」란이 비어 있는 것이 퍼블릭 클라이언트의 올바른 상태입니다(혼동이 많은 상담이라 9장에서 다시 다룹니다).

4. MSAL.NET에서의 구현 ── Silent→Interactive의 기본형

먼저 이 기본형에서 누가 무엇을 하는지 그림으로 둡니다. 앱은 비밀번호를 한 번도 받지 않고, 토큰만 받는다는 점이 읽히면 충분합니다.

Entra ID브로커 / 브라우저MSAL.NET(토큰 캐시)데스크톱 앱Entra ID브로커 / 브라우저MSAL.NET(토큰 캐시)데스크톱 앱alt[캐시에 쓸 수 있는 토큰이 있다][없거나, 갱신할 수 없다]AcquireTokenSilent액세스 토큰(화면은 나오지 않음)MsalUiRequiredExceptionAcquireTokenInteractive대화의 표시처는 구성으로 정해진다(5장)로그인(MFA / Windows Hello / FIDO)인증 결과결과를 반환(돌아오는 것은 경로마다 다름·그림2)액세스 토큰

그림1: 비밀번호 입력은 브로커 또는 브라우저 안에서 끝나고, 앱에 넘어가는 것은 토큰뿐입니다. 그래서 앱 쪽에 시크릿이 필요 없습니다. 받은 토큰으로 API를 호출하는 이후는 7장

NuGet으로 Microsoft.Identity.Client 를 추가합니다. 구현 패턴은 하나만 기억하면 됩니다. 반드시 AcquireTokenSilent을 먼저 호출하고, MsalUiRequiredException을 받았을 때만 대화형으로 fallback합니다. AcquireTokenInteractive는 캐시를 전혀 보지 않는 설계이므로, 바로 호출하면 매번 로그인 화면이 나옵니다.7

using Microsoft.Identity.Client;

public sealed class AuthService
{
    private const string ClientId = "애플리케이션(클라이언트) ID";
    private const string TenantId = "디렉터리(테넌트) ID";
    private static readonly string[] Scopes = { "User.Read" };

    private readonly IPublicClientApplication _app;

    public AuthService()
    {
        _app = PublicClientApplicationBuilder.Create(ClientId)
            .WithAuthority(AzureCloudInstance.AzurePublic, TenantId)
            .WithRedirectUri("http://localhost")  // 시스템 브라우저용
            .Build();
        // 실제 운영에서는 여기서 토큰 캐시 영속화를 등록한다(6장)
    }

    public async Task<AuthenticationResult> SignInAsync(IntPtr ownerHwnd)
    {
        // 1. 캐시된 계정으로의 사일런트 획득을 반드시 먼저 시도한다
        var accounts = await _app.GetAccountsAsync();
        var account = accounts.FirstOrDefault();
        try
        {
            return await _app.AcquireTokenSilent(Scopes, account)
                             .ExecuteAsync();
        }
        catch (MsalUiRequiredException)
        {
            // 2. 대화가 필요할 때만 로그인 화면을 연다.
            // .NET Framework의 기본값은 구식 내장 WebView이므로,
            // http://localhost 리디렉트=시스템 브라우저를 명시한다
            // (.NET 6+ 는 원래 시스템 브라우저만)
            return await _app.AcquireTokenInteractive(Scopes)
                             .WithAccount(account)
                             .WithParentActivityOrWindow(ownerHwnd)
                             .WithUseEmbeddedWebView(false)
                             .ExecuteAsync();
        }
    }
}

호출 쪽에서 오너 창의 핸들을 넘깁니다. 인증 대화 상자가 앱 뒤로 숨는 사고를 막기 위함이며, WAM에서는 필수입니다.1 한 가지 더, WithUseEmbeddedWebView(false)는 .NET Framework 앱에서는 생략하지 마세요. .NET Framework의 대화형 인증 기본값은 내장 WebView 이며, http://localhost 리디렉트는 시스템 브라우저용이기 때문입니다(조합이 어긋나면 조건부 액세스나 Windows Hello / FIDO가 동작하지 않는 구식 내장 브라우저로 떨어지거나, 리디렉트 URI 불일치가 됩니다).2 .NET 6 이후는 내장 WebView 자체가 없고 항상 시스템 브라우저이므로, 이 지정은 중복이지만 해는 없습니다.

// WinForms (Form의 메서드 안)
var result = await _authService.SignInAsync(this.Handle);

// WPF
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
var result = await _authService.SignInAsync(hwnd);

this.Text = $"로그인 중: {result.Account.Username}";

짚어 둘 포인트를 보완합니다.

  • IPublicClientApplication은 앱에서 1 인스턴스를 돌려 씁니다. 인스턴스마다 캐시를 가지므로, 호출할 때마다 Create하면 사일런트 획득이 동작하지 않습니다.
  • MsalUiRequiredException은 「이상」이 아니라 「대화가 필요하다」는 보통의 제어 흐름입니다. 최초 시작, 리프레시 토큰 실효, 조건부 액세스 요구 변경 등에서 발생합니다.
  • API를 호출하기 직전에 매번 AcquireTokenSilent을 호출하는 것이 올바른 사용법입니다. 캐시에 유효한 토큰이 있으면 즉시 돌아오고, 기한이 가까우면 자동 갱신됩니다.7 액세스 토큰을 자체적으로 들고 수명을 관리해서는 안 됩니다.
  • UI 스레드에서 .Result나 .Wait()로 기다리면 데드락합니다(「WPF/WinForms의 async와 UI 스레드를 한 장으로 정리」 참고).

5. WAM 브로커 ── Windows에서의 권장 구성

4장의 코드는 브라우저를 여는 구성이지만, Windows에서는 한 단계 나은 방법이 있습니다. WAM(Web Account Manager) 는 Windows 10(1703 이후)과 Windows Server 2019 이후에 들어 있는 인증 브로커이며, 공식 문서가 드는 이점은 다음 네 가지입니다.1

  • 보안 강화: 리프레시 토큰이 디바이스에 바인딩되어, 훔쳐 가도 다른 단말에서 쓸 수 없게 됩니다(토큰 보호). 보안 개선이 OS 쪽 업데이트로 계속 들어옵니다.
  • 기능 지원: Windows Hello, 조건부 액세스, FIDO 키 같은 OS·서비스 연동 인증 기능을 추가 코드 없이 쓸 수 있습니다.
  • 시스템 통합: Windows에 이미 로그인한 계정이 내장 계정 picker에 나오므로, 많은 경우 비밀번호 입력 없이 로그인이 끝납니다. 실질적인 SSO입니다.
  • 토큰 보호: 조건부 액세스의 토큰 보호 정책에 대응할 수 있습니다.

사내 PC가 Entra 조인(또는 Hybrid Join)해 있으면, 「앱을 시작→ Windows 계정을 고른다→ 바로 로그인 완료」라는 경험이 되고, 비밀번호를 어디에도 입력하지 않습니다.

그림1에서 「돌아오는 것은 경로마다 다르다」고 쓴 부분이 여기서의 차이입니다. 브로커는 브라우저의 대신이 아니라, 토큰 획득까지 스스로 끝낸 뒤 결과를 돌려주는 주체입니다.

쓸 수 있다쓸 수 없다 / 미지원 환경(4장)AcquireTokenInteractiveWithBroker가 유효하고WAM을 쓸 수 있는 환경인가WAM 브로커브로커 쪽에서 토큰 획득까지 끝내고,MSAL에 토큰을 돌려준다시스템 브라우저인가 코드를 MSAL에 돌려주고,MSAL이 Entra ID에서 토큰으로 교환한다MSAL이 캐시에 넣고 앱에 돌려준다

그림2: 대화를 띄우는 곳이 바뀌면 MSAL이 받는 것도 바뀝니다. 브로커 경유에서는 인가 코드 교환이 MSAL 쪽에 남지 않습니다

5.1 구현 ── WithBroker와 패키지

WAM을 쓰려면 MSAL.NET 4.52.0 이후와 추가 패키지 Microsoft.Identity.Client.Broker 가 필요합니다.1 4장의 빌더에 WithBroker를 더합니다.

using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;  // WithBroker(BrokerOptions) 용

var brokerOptions = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
{
    Title = "재고 관리 시스템"  // 계정 picker에 표시되는 제목
};

_app = PublicClientApplicationBuilder.Create(ClientId)
    .WithAuthority(AzureCloudInstance.AzurePublic, TenantId)
    .WithDefaultRedirectUri()
    .WithParentActivityOrWindow(() => _ownerHwnd)  // WAM에서는 필수
    .WithBroker(brokerOptions)
    .Build();

사일런트 획득 쪽도 한 줄 강화할 수 있습니다. 캐시에 계정이 없을 때, PublicClientApplication.OperatingSystemAccount를 넘기면 「지금 Windows에 로그인한 계정」으로 사일런트 로그인을 시도할 수 있습니다. 최초 시작부터 대화 상자 없이 로그인이 정해지는, 공식 권장 패턴입니다.9

var accounts = await _app.GetAccountsAsync();
var account = accounts.FirstOrDefault()
              ?? PublicClientApplication.OperatingSystemAccount;
try
{
    return await _app.AcquireTokenSilent(Scopes, account).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
    return await _app.AcquireTokenInteractive(Scopes).ExecuteAsync();
}

더불어 3.2절에서 말한 대로 앱 등록 쪽에 ms-appx-web://microsoft.aad.brokerplugin/{클라이언트ID}를 「모바일 애플리케이션 및 데스크톱 애플리케이션」플랫폼으로 등록해 둡니다.1 이것을 빠뜨리면 브로커 오류로 대화형 인증이 실패합니다. 한 가지 더, 위 샘플의 WithDefaultRedirectUri()는 WAM을 쓰지 못해 브라우저로 fallback했을 때의 리디렉트 URI를 정하는 것이며, 해석 대상이 플랫폼에 따라 다릅니다(.NET Framework → nativeclient, .NET → http://localhost).10 3.2절 표의 세 가지를 등록해 두었다면 어느 쪽이든 통과하지만, 등록을 좁히고 싶으면 WithRedirectUri(...)로 명시하세요.

5.2 WAM의 제약 ── 모르면 빠진다

제약 내용
OS Windows 10 (1703)+ / Windows Server 2019+. 그 이전·Mac·Linux에서는 자동으로 브라우저로 fallback한다1
ID 공급자 Entra ID 전용. Azure AD B2C·AD FS의 authority는 미지원(브라우저로 fallback)1
실행 컨텍스트 대화형 사용자 세션에서 UI를 낼 수 있는 것이 전제. Windows 서비스, 작업 스케줄러(사용자 세션 밖), runas로 다른 사용자 실행에서는 설계상 오류가 난다1

셋째 줄이 특히 중요합니다. 「화면 있는 앱에서는 도는데, 같은 코드를 야간 배치에 가져다 쓰면 실패한다」는 것은 사양입니다. 무인 실행은 사용자 위임이 아니라 애플리케이션 권한(기밀 클라이언트)으로 설계를 나누어야 하는 영역이 됩니다. fallback이 사양으로 들어 있으므로, 「WAM을 제1 후보, 안 되면 브라우저」를 코드 하나로 실현할 수 있는 것이 MSAL의 좋은 점입니다.

5.3 최종형 ── 브로커 우선+브라우저 fallback의 통합 예

4장은 브라우저만의 구성, 5.1절은 브로커 추가분만 보였으므로, 실무에서 그대로 쓸 수 있는 형태로 합칩니다. 먼저 혼동하기 쉬운 리디렉트 URI 지정 방법을 정리합니다.

쓰는 방법 실제로 쓰이는 리디렉트 URI 쓰는 곳
WithRedirectUri("http://localhost") 항상 http://localhost(시스템 브라우저용) 등록된 하나에 고정하고 싶을 때. .NET 6 이후는 이것으로 일치합니다
WithRedirectUri("https://login.microsoftonline.com/common/oauth2/nativeclient") 항상 nativeclient .NET Framework에서 내장 WebView를 쓰는 구성일 때
WithDefaultRedirectUri() 플랫폼 의존(.NET Framework → nativeclient, .NET → http://localhost)10 3.2절 표의 세 가지를 모두 등록했고, 프레임워크를 가리지 않고 같은 코드로 두고 싶을 때

어느 쪽을 골라도, WAM이 동작하는 동안 이 URI는 등장하지 않습니다. 효과가 나오는 것은 브라우저로 fallback했을 때뿐입니다. 아래 통합 예는 등록을 좁혀도 사고가 나지 않도록 WithRedirectUri로 명시적으로 고정합니다.

using System.IO;
using System.Linq;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;            // WithBroker(BrokerOptions) 용
using Microsoft.Identity.Client.Extensions.Msal;   // MsalCacheHelper 용

public sealed class AuthService
{
    private const string ClientId = "애플리케이션(클라이언트) ID";
    private const string TenantId = "디렉터리(테넌트) ID";
    private static readonly string[] Scopes = { "User.Read" };

    private readonly IPublicClientApplication _app;
    private readonly IntPtr _ownerHwnd;

    private AuthService(IPublicClientApplication app, IntPtr ownerHwnd)
    {
        _app = app;
        _ownerHwnd = ownerHwnd;
    }

    // 캐시 등록이 비동기이므로 팩터리 메서드로 둔다.
    // 앱에서 1 인스턴스만 만들어 돌려 쓴다(4장)
    public static async Task<AuthService> CreateAsync(IntPtr ownerHwnd)
    {
        var brokerOptions = new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
        {
            Title = "재고 관리 시스템"   // 계정 picker에 나오는 제목
        };

        var app = PublicClientApplicationBuilder.Create(ClientId)
            .WithAuthority(AzureCloudInstance.AzurePublic, TenantId)
            // WAM을 쓰지 못해 브라우저로 떨어졌을 때의 리디렉트 URI.
            // 해석 대상이 플랫폼에 따라 달라지는 WithDefaultRedirectUri()가 아니라,
            // 앱에 등록된 값으로 명시적으로 고정한다
            .WithRedirectUri("http://localhost")
            .WithParentActivityOrWindow(() => ownerHwnd)   // WAM에서는 필수
            .WithBroker(brokerOptions)                     // 쓸 수 없으면 자동으로 브라우저로
            .Build();

        // 토큰 캐시 영속화(6장). Build() 직후에 한 번만 등록한다
        var storageProperties = new StorageCreationPropertiesBuilder(
                "msal_cache.dat",
                Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                    "KomuraSoft", "InventoryApp"))
            .Build();
        var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
        cacheHelper.RegisterCache(app.UserTokenCache);

        return new AuthService(app, ownerHwnd);
    }

    // 이전에 로그인한 계정의 식별자. 앱 설정에 저장하고,
    // 시작 시 읽어 둔다(저장 위치는 앱 설정·레지스트리 등 무엇이든 좋다)
    private string? _homeAccountId;

    // API를 호출하기 직전에 매번 이것을 호출한다. 액세스 토큰을 자체적으로 들지 않는다(4장)
    public async Task<AuthenticationResult> AcquireTokenAsync()
    {
        // 1. 어느 계정으로 silent를 시도할지 정한다
        IAccount? account = await ResolveAccountAsync();

        if (account is not null)
        {
            try
            {
                return await _app.AcquireTokenSilent(Scopes, account).ExecuteAsync();
            }
            catch (MsalUiRequiredException)
            {
                // 대화형으로 전환한다
            }
        }

        // 2. 대화. WAM을 쓸 수 있으면 계정 picker,
        //    쓸 수 없으면 위에서 지정한 시스템 브라우저가 열린다
        AuthenticationResult result =
            await _app.AcquireTokenInteractive(Scopes)
                      .WithParentActivityOrWindow(_ownerHwnd)
                      .WithUseEmbeddedWebView(false)  // .NET Framework용(4장)
                      .ExecuteAsync();

        // 고른 사람을 기억한다. 다음에는 이 사람으로 silent를 시도한다
        _homeAccountId = result.Account?.HomeAccountId?.Identifier;
        SaveHomeAccountId(_homeAccountId);
        return result;
    }

    private async Task<IAccount?> ResolveAccountAsync()
    {
        List<IAccount> accounts = (await _app.GetAccountsAsync()).ToList();

        // 이전에 고른 사람이 캐시에 남아 있으면 그 사람
        if (_homeAccountId is not null)
        {
            IAccount? saved = accounts.FirstOrDefault(
                a => a.HomeAccountId?.Identifier == _homeAccountId);
            if (saved is not null) { return saved; }
        }

        // 후보가 한 사람뿐이면 그것을 써도 된다
        if (accounts.Count == 1) { return accounts[0]; }

        // 0명이거나, 여러 명이 있어 결정 단서가 없다.
        // 여기서 FirstOrDefault를 쓰지 말 것(아래)
        return null;
    }
}

GetAccountsAsync()의 결과를 FirstOrDefault()로 받지 마세요. 캐시에 들어 있는 계정이 하나라고 단정할 수 없습니다. 다른 계정으로 전환한 뒤, 공유 단말에서 여러 사람이 쓴 뒤, 테넌트를 건너 검증한 뒤 ── 어느 쪽이든 여러 개가 남습니다. 열거 순서에는 의미가 없으므로, FirstOrDefault()는 「우연히 맨 앞에 있던 사람」을 조용히 고릅니다.

이것이 까다로운 이유는 잘못 골라도 화면에 아무것도 나오지 않기 때문입니다. 그 계정의 토큰이 캐시에 살아 있으면 AcquireTokenSilent은 성공하고, 계정 picker는 열리지 않습니다. 이용자는 자기 것으로 조작하고, 실제로는 다른 사람의 Graph 데이터를 표시·갱신합니다. 아무도 알아채지 못합니다.

그래서 위 코드에서는 다음 순서로 정합니다.

상황 어떻게 하는가
이전에 고른 사람이 캐시에 남아 있다 그 사람으로 silent를 시도한다
후보가 한 사람뿐이다 그 사람으로 silent를 시도한다
0명이거나, 여러 명이 있어 결정 단서가 없다 silent를 시도하지 않고, 대화로 사용자에게 고르게 한다

HomeAccountId.Identifier는 「어느 테넌트의 어느 사용자인가」를 나타내는 문자열이므로, 이것을 저장해 두면 다음에도 같은 사람으로 들어갈 수 있습니다(토큰이 아니므로 민감 정보로 다룰 필요는 없습니다). Windows의 로그인 중 계정을 기본으로 두고 싶은 구성이라면, 마지막 줄을 PublicClientApplication.OperatingSystemAccount로 하는 설계도 있을 수 있습니다. 피해야 할 것은 「순서로 정하는」 것뿐입니다.

호출 쪽은 다음과 같습니다. CreateAsync는 앱 시작 시 한 번만 실행하고, 반환값을 유지하세요.

// 폼의 필드로 둔다(앱에서 1 인스턴스)
private AuthService _authService;

// 버튼의 Click 핸들러 등에서. WPF는 4장과 같이 WindowInteropHelper로 HWND를 얻는다
private async Task SignInAsync()
{
    _authService ??= await AuthService.CreateAsync(this.Handle);
    var result = await _authService.AcquireTokenAsync();
    this.Text = $"로그인 중: {result.Account.Username}";
}

이 하나로, WAM을 쓸 수 있는 단말에서는 Windows 계정만 고르고, 쓸 수 없는 단말에서는 시스템 브라우저, 다시 시작한 뒤에는 캐시에서의 사일런트 획득이라는 세 경로가 모두 성립합니다. 나머지는 앱 등록 쪽에 3.2절 표의 URI(적어도 ms-appx-web://...와 http://localhost)가 들어 있는지만 확인하면 됩니다.

6. 토큰 캐시 영속화 ── 다시 시작할 때마다 로그인 화면을 내지 않는다

MSAL.NET의 토큰 캐시는 기본값이 메모리뿐이며, 데스크톱 앱에서는 영속화 구현이 앱 쪽 책임입니다. 영속화하지 않으면 프로세스를 다시 시작할 때마다 AcquireTokenSilent이 실패해 대화형 로그인이 됩니다.7 「도입 테스트에서는 좋았는데, 매일 아침 로그인 화면이 나온다고 현장에서 불만이 왔다」는 상담의 원인은 거의 이것입니다.

공식 권장은 크로스 플랫폼 캐시 라이브러리 Microsoft.Identity.Client.Extensions.Msal(NuGet)를 쓰는 것입니다.3

using Microsoft.Identity.Client.Extensions.Msal;

var storageProperties = new StorageCreationPropertiesBuilder(
        "msal_cache.dat",
        Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "KomuraSoft", "InventoryApp"))
    .Build();

var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
cacheHelper.RegisterCache(_app.UserTokenCache);  // Build() 직후에 한 번 등록

Windows에서는 캐시가 암호화되어 저장됩니다. 공식 문서에 실린 자체 구현 예는 ProtectedData(DPAPI, DataProtectionScope.CurrentUser)로 토큰을 암호화해 파일로 저장하는 형태이며, Extensions.Msal은 그것을 제품 품질로 다듬은 라이브러리라는 위치입니다.3 「사용자 단위 비밀은 사용자 스코프 DPAPI로 지킨다」는 원칙은 「Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피한다」에서 쓴 설정 파일 이야기와 같습니다. 토큰 캐시를 평문 JSON으로 저장하는 자체 구현은 연결 문자열의 평문 저장과 같은 심각도로 다루세요.

운영 면의 주의 세 가지.

  • WAM을 쓰는 경우에도 캐시 영속화는 필요합니다. MSAL이 ID 토큰과 계정 메타데이터를 계속해서 자기 캐시에 저장하기 때문입니다.9
  • 저장 위치는 %LOCALAPPDATA%\회사명\앱이름이 기본입니다. DPAPI 연결 관계로 다른 PC·다른 사용자에서는 복호화할 수 없지만, 사일런트 획득이 실패해 다시 로그인하는 것으로 끝나므로 실제 피해는 없습니다.
  • 「로그아웃」은 GetAccountsAsync로 열거한 계정을 RemoveAsync로 지워 구현합니다. 캐시 파일 삭제는 필요 없습니다. 다만 RemoveAsync가 지우는 것은 MSAL의 로컬 캐시뿐이며, WAM·브라우저·Windows 로그인 쪽 세션은 그대로 남습니다. 다음 대화형 로그인에서 같은 계정이 사일런트로 다시 로그인될 수 있으므로, 공유 PC에서 계정 전환을 성립시키고 싶으면 AcquireTokenInteractive에 WithPrompt(Prompt.SelectAccount)를 붙여 반드시 계정 선택 화면을 내거나, 요건에 따라 테넌트의 로그아웃 엔드포인트도 병용하는 등, 「로컬 캐시 삭제」와 「진짜 사인아웃」을 구분해 설계하세요.

7. 얻은 토큰으로 무엇을 하는가 ── 세 가지 구성

인증이 통과한 뒤의 쓰임새는 세 패턴으로 나뉩니다. 어디까지 할지에 따라 필요한 설정도 달라집니다.

구성 쓰는 토큰 추가로 필요한 것
(1) 로그인만 ID 토큰(AuthenticationResult.Account / ClaimsPrincipal) 없음(User.Read만)
(2) Microsoft Graph를 호출한다 Graph용 액세스 토큰 호출할 API에 맞는 Graph 사용 권한과 동의
(3) 자사 Web API를 지킨다 자사 API용 액세스 토큰 API 쪽 앱 등록과 스코프 공개, API 쪽에서의 토큰 검증

7.1 로그인만의 구성 ── 가장 작게 시작한다

「자체 비밀번호 조회만 바꾸고 싶을 뿐, 클라우드 API는 호출하지 않는다」면, 로그인 결과의 계정 정보를 앱 안의 권한 테이블과 대조하는 것만으로 성립합니다. users 테이블의 키를 Entra의 개체 ID(성 변경 등으로 UPN이 바뀌어도 불변)로 바꾸고, 비밀번호 열을 삭제합니다. 로컬 DB 설계는 바꾸지 않고 인증만 갈아 끼울 수 있으므로, 첫걸음으로 가장 권하기 쉬운 구성입니다.

7.2 Microsoft Graph를 호출한다

User.Read의 액세스 토큰을 그대로 Microsoft Graph에 던지면, 로그인한 사용자의 프로필이나 사진을 얻을 수 있습니다.

var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", result.AccessToken);
var me = await http.GetStringAsync("https://graph.microsoft.com/v1.0/me");

일정·메일 송신·Teams 알림 등으로 넓히는 경우에는 대응하는 사용 권한(Mail.Send 등)을 추가하고 관리자 동의를 다시 받습니다. 사내 앱에서의 알림 메일을 Graph로 모으는 설계는 「중소기업을 위한 일괄 메일 발송을, 특정 서비스에 묶이지 않고 설계하는 방법」에서 다룬 흐름과도 맞물립니다.

7.3 자사 Web API를 지킨다 ── audience와 스코프 검증까지

데스크톱 앱이 자사 Web API를 호출하는 구성이면, API 쪽에도 별도의 앱 등록을 만들고, api://{API의 클라이언트ID}/access_as_user 같은 스코프를 공개한 뒤, 데스크톱 쪽은 그 스코프로 토큰을 요청합니다. API 쪽에서 중요한 것은 [Authorize]만 붙이는 것으로는 불충분하다고 공식 문서에 명시된 점입니다.11 검증해야 할 것은 다음 세 단계입니다.

  1. 서명과 발급자: 올바른 테넌트의 Entra ID가 발급한 JWT인가(ASP.NET Core + Microsoft.Identity.Web이면 미들웨어가 처리)
  2. audience(aud): 토큰의 대상이 이 API 자신인가. Graph용 토큰을 자사 API에 돌려 쓰지 않는다
  3. 스코프(scp 클레임): 기대하는 스코프가 들어 있는가. Microsoft.Identity.Web이면 [RequiredScope("access_as_user")] 특성으로 선언할 수 있다11

2와 3을 빼면 「Entra 토큰처럼 보이면 누구든 통과하는 API」가 됩니다. 「Windows 앱 개발의 보안 최소 체크리스트」의 통신·입력 검증 항목과 함께 설계 리뷰 대상으로 두세요.

8. 도입 판단 ── 완전 사내 도구에 Entra 인증을 넣어야 하는가

모든 사내 앱에 넣어야 하느냐 하면, 그렇지는 않습니다. 먼저 판정 순서를 그림으로 둡니다. 네트워크와 ID 기반이라는 두 전제가 앞에 오고, 그다음에 앱 쪽 사정을 봅니다.

완전 오프라인 구역이 있다도달할 수 있다온프레미스 AD만Google Workspace만Microsoft 365 / Entra ID없다(단기능 변환 도구 등)있다앱이 동작하는 환경에서Entra ID에 도달할 수 있는가불가, 또는 설계 필요토큰 갱신에 네트워크가 필요하다조직의 ID 기반은 무엇인가Windows 통합 인증을 검토한다Google 쪽 구조를 검토한다앱에 로그인 개념,API 호출, 감사 요건 중 하나가 있는가도입하지 않는다Windows 로그온으로 충분하다도입한다자체 비밀번호 관리를 통째로 놓을 수 있다

그림3: 판단은 위에서부터 순서입니다. 오프라인 요건과 ID 기반으로 먼저 좁힌 뒤, 앱 쪽 필요성을 봅니다

상황 권장 이유
Microsoft 365 / Entra ID를 전사 도입했고+앱에 로그인 개념이 있다 도입한다 자체 비밀번호 관리의 부채가 통째로 사라진다. 구현 비용은 작다
앱이 자사 Web API나 클라우드 자원을 호출한다 도입한다 API 보호에 인증 기반은 필수. 자체 토큰을 발명하는 것보다 확실하다
감사 요건이 있다(누가 언제 썼는지의 기록, MFA 필수화) 도입한다 로그인 로그·조건부 액세스가 테넌트 쪽에서 한곳으로 모인다
로그인 개념이 없는 단기능 도구(변환 도구, 뷰어 등) 불필요 인증을 더할 동기가 없다. Windows 로그온으로 충분하다
완전 오프라인 환경(폐쇄망 제조 라인, 반출 PC)에서 동작한다 불가 또는 설계 필요 최초 로그인과 토큰 갱신에 네트워크가 필수
Entra ID 미도입(온프레미스 AD만, Google Workspace만) 다른 방안을 검토 전자는 AD 인증(Windows 통합 인증), 후자는 Google 쪽 구조가 자연스럽다

오프라인 요건은 특히 주의하세요. AcquireTokenSilent은 캐시 안의 액세스 토큰이 유효한 동안(경험칙으로 대략 1시간 남짓)은 오프라인에서도 돌려줄 수 있지만, 기한이 끝나면 갱신에 네트워크가 필요합니다. 도입 전에 이 수명 전제로 업무가 돌아가는지를 현장 이용 패턴과 맞춰 봐야 합니다.

9. 운영의 함정 ── 도입 후에 오는 문의

도입하고 끝이 아니라, 운영 단계에서 전형적인 문의가 있습니다. 미리 적어 둡니다.

  • 「어제까지 됐는데 갑자기 로그인할 수 없다」: 제1 용의자는 조건부 액세스 정책의 변경입니다. 정보시스템 부서가 「미등록 디바이스 차단」등을 켜면, 앱은 아무것도 바꾸지 않았는데 로그인이 실패하기 시작합니다. 원인 분리는 Entra 관리 센터의 로그인 로그에서 해당 사용자의 오류 이유를 보는 것이 가장 빠릅니다. WAM 구성으로 두면 정책 요구에 대한 대응력이 올라가고, 이런 마찰 자체가 줄어듭니다.1
  • 「시크릿 유효 기간이 끝난다는 알림이 왔는데, 이 앱은 괜찮은가」: 퍼블릭 클라이언트에는 시크릿도 인증서도 애초에 없으므로 기한 만료도 없습니다. 이 문의가 오면, 기밀 클라이언트 앱 등록과의 혼동이거나, 누군가 퍼블릭 클라이언트용 등록에 불필요한 시크릿을 만든 것입니다(후자라면 지워도 됩니다). 시크릿 기한 만료로 인한 정지 사고가 구조적으로 일어나지 않는 것은 이 구성의 숨은 이점입니다.
  • 「최초 시작에서 『관리자 승인이 필요합니다』가 나온다」: 3.3절의 관리자 동의 누락입니다. 사용 권한을 나중에 추가한 경우에도, 추가분의 동의를 다시 받을 때까지 같은 표시가 나옵니다.
  • 「야간 배치에 넣었더니 동작하지 않는다」: 5.2절에서 말한 대로 WAM은 대화 세션이 전제입니다. 무인 처리는 사용자 위임 토큰을 돌려 쓰는 것이 아니라, 애플리케이션 권한으로의 별도 설계로 둡니다.
  • 배포와 업데이트: MSAL 주변은 수정이 활발하므로, 라이브러리 업데이트를 전 단말에 끝까지 배포하는 구조가 필요합니다. 「자동 업데이트의 보안 설계」에서 쓴 업데이트 경로 검증과 세트로 생각하세요.

10. 정리

WinForms / WPF 앱의 Entra ID 인증 대응은 다음 여섯 가지로 모입니다.

  • 자체 비밀번호 관리를 그만두는 것 자체가 목적. 보관·재설정·퇴사자 대응·감사가 테넌트 쪽으로 한곳에 모인다
  • 데스크톱 앱은 퍼블릭 클라이언트. 시크릿은 가질 수 없고, 필요도 없다
  • ROPC는 폐지 방향. 사용자 이름·비밀번호를 맡는 화면은 새로 만들지 않는다
  • 구현은 MSAL.NET의 AcquireTokenSilent → AcquireTokenInteractive 패턴 한 가지
  • Windows에서는 WAM 브로커(WithBroker) 로 SSO·조건부 액세스·Windows Hello 대응
  • 토큰 캐시 영속화(Extensions.Msal / DPAPI 보호)를 처음부터 넣는다

「로그인만 갈아 끼운다」는 최소 구성(7.1절)이면, 기존 앱에 대한 영향은 로그인 화면과 사용자 테이블 주변으로 한정할 수 있고, 며칠 규모의 수정으로 끝나는 경우가 많습니다. 한편 조건부 액세스나 오프라인 요건이 얽히면, 테넌트 쪽 설정과 업무 실태를 바탕으로 한 설계 판단이 필요합니다. 손안의 앱을 어느 구성까지 가져갈지, 자체 인증에서의 이전 절차를 어떻게 나눌지, 판단이 막히면 도와드릴 수 있습니다.

관련 기사

관련 상담 영역

合同会社小村ソフト에서는 기존 WinForms / WPF 앱에 Entra ID 인증을 넣는 일(앱 등록 설계, MSAL.NET 구현, 자체 인증에서의 이전 계획), 자사 Web API의 토큰 검증 설계 리뷰, 조건부 액세스가 얽힌 로그인 장애의 원인 분리를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Using MSAL.NET with Web Account Manager (WAM). 브로커의 이점(보안 강화, Windows Hello·조건부 액세스·FIDO 대응, 계정 picker, 토큰 보호), MSAL.NET 4.52.0+와 Microsoft.Identity.Client.Broker 패키지, WithBroker와 부모 창 핸들 필수, ms-appx-web 리디렉트 URI, 대응 OS와 fallback, 대화 세션 필수 등의 제약에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11

  2. Microsoft Learn, Using web browsers (MSAL.NET). 프레임워크별 브라우저 대응 표(.NET Framework 4.6.2+의 기본값이 내장, .NET 6+는 시스템 브라우저만), 시스템 브라우저에는 http://localhost 리디렉트 URI가 필요한 것, WithUseEmbeddedWebView에 의한 전환에 대해. ↩ ↩2

  3. Microsoft Learn, Token cache serialization. 데스크톱 앱은 Microsoft.Identity.Client.Extensions.Msal의 크로스 플랫폼 캐시를 쓰라는 권장, MsalCacheHelper 사용법, ProtectedData(DPAPI, CurrentUser 스코프)에 의한 자체 직렬화 예에 대해. ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, Desktop app that calls web APIs: Code configuration. 데스크톱 앱의 리디렉트 URI(모바일 및 데스크톱 플랫폼, nativeclient / localhost), 「퍼블릭 클라이언트 흐름 허용」설정의 의미에 대해. ↩ ↩2 ↩3

  5. Microsoft Learn, Microsoft identity platform and OAuth 2.0 Resource Owner Password Credentials. ROPC를 쓰지 말아야 하는 것, MFA와 호환되지 않아 차단되는 것, ROPC 의존 앱이 막히는 방향인 것, 데스크톱 앱은 브로커 기반 인증으로 이전해야 하는 것에 대해. ↩ ↩2 ↩3 ↩4

  6. Microsoft Learn, Desktop app that calls web APIs: Acquire a token using username and password. 사용자 이름·비밀번호 흐름(ROPC)이 보안 위험이므로 폐지(deprecated)된 것, 마이그레이션 가이드 안내, MFA·조건부 액세스·SSO 미지원 제약에 대해. ↩ ↩2

  7. Microsoft Learn, Get a token from the token cache using MSAL.NET. AcquireTokenSilent을 먼저 호출하고 MsalUiRequiredException으로 대화형에 fallback하는 권장 패턴, 캐시와 리프레시 토큰에 의한 자동 갱신, 계정 삭제에 의한 캐시 클리어에 대해. ↩ ↩2 ↩3 ↩4

  8. Microsoft Learn, Register an application with the Microsoft identity platform. Entra 관리 센터에서의 앱 등록 절차, 지원되는 계정 종류 선택, 클라이언트 ID 취득, 관리자 동의에 대해. ↩ ↩2

  9. Microsoft Learn, Desktop app that calls web APIs: Acquire a token by using WAM. WAM 이용 시에도 토큰 캐시 영속화가 필요한 것, OperatingSystemAccount에 의한 사일런트 로그인 권장 패턴, 앱 등록 쪽 리디렉트 URI 설정에 대해. ↩ ↩2 ↩3

  10. Microsoft Learn, Default reply URI. WithDefaultRedirectUri가 설정하는 리디렉트 URI가 플랫폼에 따라 다른 것(.NET Framework 데스크톱은 https://login.microsoftonline.com/common/oauth2/nativeclient, .NET Core는 http://localhost)에 대해. ↩ ↩2 ↩3

  11. Microsoft Learn, Protected web API: Verify scopes and app roles. [Authorize] 특성만으로는 불충분하며 scp 클레임(스코프) 검증이 필요한 것, Microsoft.Identity.Web의 RequiredScope 특성에 의한 선언적 검증에 대해. ↩ ↩2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

WinForms/WPF 앱에 Entra ID 인증을 넣는 이점은 무엇인가요?
비밀번호 보관·재설정 대응·퇴사자 계정 정지·로그인 감사가 모두 테넌트 쪽 일이 되어, 앱 쪽 책임 범위가 극적으로 작아집니다. 퇴사자는 Entra ID 계정을 비활성화하면 모든 앱에서 즉시 로그인할 수 없고, 다요소 인증이나 조건부 액세스도 그대로 사내 앱에 적용됩니다. 구현도 MSAL.NET이라는 라이브러리와 수십 줄의 코드로 끝나는 범위입니다. Microsoft 365를 이미 도입한 조직이 자체 인증을 계속할 이유는 거의 없습니다.
사용자 이름과 비밀번호를 자체 화면에서 받아 인증하는 방식(ROPC)을 쓸 수 있나요?
신규 채택은 금지로 생각하세요. 퍼블릭 클라이언트용 ROPC는 공식 문서에 「보안 위험이므로 폐지(deprecated)」라고 명시되어 있고, 마이그레이션 가이드가 공개되어 있습니다. MFA·조건부 액세스와 호환되지 않으므로, 테넌트에서 MFA가 필수가 된 사용자는 차단되어 로그인할 수 없습니다. MFA 필수는 테넌트 쪽 설정으로 언제든 일어날 수 있어, 지금은 동작하더라도 어느 날 갑자기 전원이 로그인하지 못하게 되는 위험이 있습니다.
WAM 브로커는 쓰는 편이 좋나요?
Windows에서는 권장입니다. WithBroker 한 줄로, Windows에 이미 로그인한 계정과의 SSO, 조건부 액세스·Windows Hello·FIDO 키 대응, 리프레시 토큰의 디바이스 바인딩을 얻습니다. 사내 PC가 Entra에 조인해 있으면, 앱 시작 후 Windows 계정만 고르면 비밀번호 입력 없이 로그인이 끝납니다. 다만 Entra ID 전용이며, Windows 서비스나 작업 스케줄러처럼 대화형 사용자 세션 밖에서는 설계상 오류가 나는 제약이 있습니다.
앱을 다시 시작할 때마다 로그인 화면이 나오는 이유는 무엇인가요?
토큰 캐시를 영속화하지 않았기 때문입니다. MSAL.NET의 토큰 캐시는 기본값이 메모리뿐이며, 데스크톱 앱에서는 영속화 구현이 앱 쪽 책임입니다. 공식 권장은 Microsoft.Identity.Client.Extensions.Msal 패키지이며, Windows에서는 캐시가 암호화되어 저장됩니다. WAM을 쓰는 경우에도 ID 토큰과 계정 메타데이터를 저장하려면 캐시 영속화가 필요합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기