구글 로그인 구현하다가 3일 밤샌 썰 (OAuth 2.0의 원리와 보안)
1. "그냥 버튼 하나 달면 되는 거 아냐?"
서비스 런칭을 앞두고 있었습니다. 베타 테스터들에게서 "이메일/비밀번호 입력하기 귀찮아서 가입 안 해요"라는 피드백을 받았죠. 그래서 결정했습니다. "구글 로그인(OAuth)을 붙이자!"
문서 좀 보고 API 호출하면 끝날 줄 알았습니다.
하지만 현실은 400: redirect_uri_mismatch, invalid_grant 같은 알 수 없는 에러 메시지와의 전쟁이었습니다.
2. OAuth는 "사교 댄스"다
처음엔 OAuth가 그냥 "비밀번호 대신 키를 주는 것"인 줄 알았습니다. 하지만 알고 보니 이건 정해진 순서대로 스텝을 밟아야 하는 사교 댄스였습니다. 스텝이 하나라도 꼬이면 음악(로그인)은 멈춥니다.
1단계 - 춤 신청 (Authorization Request)
사용자가 "구글 로그인" 버튼을 누릅니다. 제 앱(Client)이 구글(Provider)에게 말합니다.
"저기요, 이 사용자가 저랑 춤추고 싶다는데요? (로그인하고 싶대요)" "다 끝나면
http://localhost:3000/callback여기로 보내주세요." "그리고 저는 이 사람의profile만 볼래요. (Scope)"
2단계 - 파트너 승낙 (User Consent)
구글이 사용자에게 묻습니다.
"이 앱이 님 이메일이랑 프로필 보겠다는데, 허락해요?"
사용자가 "허용"을 누르면 구글은 승낙의 표시로 임시 티켓(Authorization Code)을 줍니다. 이건 진짜 열쇠가 아닙니다. 그냥 "사용자가 허락했음"을 증명하는 종이쪼가리입니다.
3단계 - 티켓 교환 (Token Exchange)
이게 제일 헷갈렸던 부분입니다. 왜 바로 토큰을 안 주고 코드를 줄까요? 보안 때문입니다. 사용자의 브라우저(URL)를 통해 전달되는 코드는 누구나 훔쳐볼 수 있습니다.
그래서 제 서버(Server)가 구글 서버에게 몰래 가서 티켓을 내밉니다.
이때 Client Secret이라는 진짜 비밀번호를 같이 보여줍니다.
"아까 그 사용자가 준 티켓(Code)인데요. 저 진짜 이 앱 주인 맞거든요(Client Secret)? 진짜 열쇠(Access Token)로 바꿔주세요."
4단계 - 춤 시작 (API Access)
구글이 확인하고 진짜 열쇠(Access Token)를 줍니다. 이제 제 서버는 그 열쇠로 "사용자 이메일 내놔"라고 당당하게 요구할 수 있습니다.
3. 내가 겪은 3가지 악몽
이 댄스 스텝을 밟으면서 저는 세 번 넘어졌습니다.
악몽 1: redirect_uri_mismatch
구글 개발자 콘솔에는 http://localhost:3000/api/auth/callback/google이라고 등록했습니다.
근데 실제로는 http://127.0.0.1:3000으로 접속해서 테스트했죠.
구글은 이 둘을 완전히 다른 사이트로 취급합니다.
교훈: 등록한 주소와 토씨 하나 안 틀리고 똑같아야 합니다. Trailing Slash(/) 유무도 검사합니다.
악몽 2 - State 파라미터 무시 (CSRF 공격)
"이건 선택 사항(Optional)이라네? 귀찮으니까 패스." 나중에 알았습니다. 이게 없으면 CSRF 공격에 뚫립니다.
해커가 자기 구글 계정의 Authorization Code를 제 앱의 콜백 URL에 억지로 밀어넣습니다.
그러면 피해자는 자기도 모르게 해커의 계정으로 로그인하게 되고, 피해자가 저장한 신용카드 정보가 해커의 계정에 저장됩니다.
state는 "이 요청이 내가 보낸 게 맞다"라고 증명하는 난수(Random String)입니다.
악몽 3 - 모바일 앱의 딥링크 (Mobile Deep Linking)
웹에서는 잘 됐는데, React Native 앱을 만드니 또 안 됩니다.
앱은 URL이 없으니까요.
결국 myapp://callback 같은 커스텀 스킴(Custom Scheme)을 써야 했고, iOS/Android 설정을 따로 해줘야 했습니다.
PKCE (Proof Key for Code Exchange) 깊이 들여다보기
모바일 앱이나 SPA(Single Page App)는 Client Secret을 가질 수 없습니다 (소스코드를 뜯어보면 다 나오니까요).
비밀번호 없이 어떻게 "나 주인 맞다"고 증명할까요?
여기서 PKCE (픽시라고 읽음)가 등장합니다.
- 시작할 때: 앱이 랜덤한 문자열(
code_verifier)을 만들고, 이걸 암호화(code_challenge)해서 구글에 보냅니다. - 토큰 바꿀 때: 아까 만든 원본 문자열(
code_verifier)을 보냅니다. - 구글: "아, 아까 그 암호화된 문자열의 원본이 이거구나? 너 아까 걔 맞네!"
이제 모바일 앱에서도 비밀번호 없이 안전하게 로그인할 수 있습니다.
5. 해결책 - 바퀴를 다시 발명하지 마라
3일 동안 맨땅에 헤딩하면서 OAuth 코드를 직접 짰습니다. 그런데 나중에 보니 NextAuth.js (지금의 Auth.js) 라는 라이브러리가 있더군요.
제가 300줄로 짠 코드를, 이 라이브러리는 설정 파일 10줄로 끝내버렸습니다.
/* pages/api/auth/[...nextauth].js */
import GoogleProvider from "next-auth/providers/google";
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) token.accessToken = account.access_token;
return token;
},
},
});
허무했습니다. 하지만 동시에 안도했습니다. "이제 유지보수(토큰 갱신, 세션 관리, 보안 패치) 내가 안 해도 된다!"
6. 마무리 - 원리는 알되, 라이브러리를 쓰자
OAuth 2.0의 원리(Code Flow, Refresh Token, PKCE)를 이해하는 건 중요합니다. 그래야 에러가 났을 때 어디가 문제인지, 보안 위협이 어디에 있는지 아니까요.
하지만 실제로 직접 구현하지는 마세요. 보안은 전문가들이 만든, 검증된 라이브러리(NextAuth, Supabase Auth, Clerk 등)를 쓰는 게 정신 건강에도, 사용자에게도 좋습니다.
여러분의 3일은 소중하니까요. 이제 그 시간으로 더 멋진 기능을 만드세요.
I Spent 3 Days Implementing 'Login with Google' (OAuth 2.0 Deep Dive)
1. "It's Just One Button, Right?"
I was about to launch my service. Beta testers complained, "Can't I just use Google login? I hate typing passwords." So I decided. "Let's add OAuth!"
I thought I just needed to read the docs and call an API.
Reality was a war against 400: redirect_uri_mismatch, invalid_grant, and mysterious CORS errors.
2. OAuth is a "Social Dance"
I thought OAuth was just "giving a key instead of a password." But it turns out, it's a Social Dance with strict steps. Cross one leg wrong, and the music (login) stops.
Step 1: The Dance Request (Authorization Request)
User clicks "Login with Google". My App (Client) tells Google (Provider):
"Hey, this user wants to dance with me (login). When done, send them to
http://localhost:3000/callback. I only want to see theirprofile. (Scope)"
Step 2: User Consent
Google asks the user:
"This app wants to see your email and profile. Allow?"
User clicks "Allow". Google gives a Temporary Ticket (Authorization Code) as a sign of approval. This is NOT the key. It's just a slip of paper saying "User said yes".
Step 3: Ticket Exchange (Token Exchange)
This was the most confusing part. Why give a Code first, not the Token? Because of security. The user's browser (URL bar) is public territory. Any malicious extension could steal the token if it were sent there.
So My Server goes to Google's Server secretly (Back-channel) with the ticket.
I also show my ID card (Client Secret).
"Here's the ticket from that user. And here is my Secret ID. Give me the Real Key (Access Token)."
Step 4: Start Dancing (API Access)
Google verifies the ticket and ID, then hands over the Access Token. Now my server can confidentally ask, "Give me the user's email," using that token.
3. My 3 Nightmares
While dancing, I tripped three times.
Nightmare 1: redirect_uri_mismatch
I registered http://localhost:3000/api/auth/callback/google in Google Console.
But I tested using http://127.0.0.1:3000.
Google treats these as completely different sites.
Lesson: The URL must match EXACTLY, character for character. Even a trailing slash / matters.
Nightmare 2: Ignoring State Parameter (CSRF Attack)
"Docs say it's Optional? I'm lazy, I'll update it later." Later I learned this leaves me open to Login CSRF attacks.
A hacker forces their Authorization Code into my callback URL.
I unknowingly log in as the hacker. I enter my credit card info. It gets saved to the hacker's account.
The state parameter is a random string that proves "I started this request".
Nightmare 3: Mobile Deep Linking
It worked on Web, but failed on React Native.
Apps don't have URLs like https://....
I had to register a Custom Scheme (myapp://callback) and configure iOS/Android manifest files. It was hellish.
4. Deep Dive: PKCE (Proof Key for Code Exchange)
Mobile apps and SPAs (Single Page Apps) cannot keep a Client Secret. (Anyone can decompile code or inspect network traffic).
So how do they prove "I am the owner" without a password?
Enter PKCE (Pronounced 'Pixy').
- Start: App generates a random string (
code_verifier) and encrypts it (code_challenge). Sends the encrypted version to Google. - Exchange: App sends the original string (
code_verifier) along with the Code. - Google: "Ah, if I encrypt this original string, it matches what you sent me earlier! You are the same app."
This allows public clients to do OAuth securely without secrets.
5. Solution: Don't Reinvent the Wheel
I spent 3 days writing raw OAuth code. Then I found NextAuth.js (Auth.js).
My 300 lines of spaghetti code were replaced by 10 lines of config.
/* pages/api/auth/[...nextauth].js */
import GoogleProvider from "next-auth/providers/google";
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) token.accessToken = account.access_token;
return token;
},
},
});
I felt empty. But also relieved. "I don't have to maintain token refreshes, session storage, or security patches anymore!"
6. Conclusion: Understand It, But Use a Library
Understanding OAuth 2.0 principles (Code Flow, Refresh Token, PKCE) is crucial. You need it to debug when things go wrong.
But do not implement it yourself in production. Security is hard. Use battle-tested libraries (NextAuth, Supabase Auth, Clerk, etc.). It's better for your mental health and your users' safety.
Your 3 days are too precious. Go build something cool instead.
7. Troubleshooting: "It works on my machine"
If you are stuck, check this checklist.
Checklist for redirect_uri_mismatch
- Did you add
http://localhost:3000ANDhttp://127.0.0.1:3000? - Did you include the full path? (
/api/auth/callback/google) - Did you check for a trailing slash? (
/at the end matters!) - Are you using
httpsin production?
Checklist for Mobile Apps
- Did you register the package name (Android) and Bundle ID (iOS) in Google Cloud Console?
- Did you download the
google-services.json(Android) andGoogleService-Info.plist(iOS)? - Are you using a custom scheme like
com.myapp://?
Checklist for "Invalid Grant"
- The Authorization Code is valid for one-time use only.
- Did you retry the request with the same code?
- Is your system time synchronized? (NTP)
OAuth is hard because it's strict. But once you get it right, it's the gold standard for authentication. Don't give up!