Supabase 로그인 세션으로 "내 API" 호출하기 (Next.js · Bearer 토큰)

지난 글에서 Supabase + Next.js로 구글·카카오·페이스북 소셜 로그인을 구현했습니다. 그런데 로그인은 시작일 뿐입니다. 진짜 하고 싶은 건 이거죠.
"로그인한 사용자만 접근할 수 있는 내 API를 만들고, 프론트에서 로그인 정보로 그 API를 호출하고 싶다."
이 글은 그 방법을 두 가지 시나리오로, 실제로 E2E 테스트까지 통과한 코드로 보여줍니다.
- 같은 사이트(브라우저) → Next.js API 라우트 : 쿠키 세션을 자동으로 사용
- 외부 클라이언트(다른 서버·앱·CLI) → 내 API :
Authorization: Bearer <access_token>헤더 사용

0. 핵심 개념: 세션은 어떻게 "내 API"까지 전달되나?
로그인에 성공하면 Supabase는 access_token(JWT) 을 발급합니다. 이 토큰이 "나는 로그인한 누구다"를 증명하는 증표입니다. 이 토큰을 내 API에 전달하는 방법이 두 가지입니다.
| 전달 방법 | 누가 쓰나 | |
|---|---|---|
| 쿠키 방식 | 브라우저가 같은 사이트 요청에 쿠키를 자동 첨부 | 같은 도메인의 프론트엔드 |
| Bearer 방식 | 요청 헤더에 Authorization: Bearer <토큰> 을 직접 첨부 | 다른 서버, 모바일 앱, CLI 등 |
그리고 내 API는 두 경우 모두 "이 토큰이 진짜인지" 서버에서 검증해야 합니다. Supabase에서는 supabase.auth.getUser() 가 그 검증을 해 줍니다.
🔑 가장 중요한 보안 규칙: 사용자 신원은 반드시 서버에서
getUser()로 검증합니다.getUser()는 토큰을 Supabase에 보내 진짜인지 확인합니다. 반면getSession()은 저장된 값을 그냥 읽기만 하므로(검증 X), 권한 판단에는 쓰지 않습니다.
1. 보호된 API 라우트 만들기 (쿠키 + Bearer 모두 지원)
Next.js의 Route Handler로 /api/me 엔드포인트를 만듭니다. 로그인한 사용자에게는 자기 정보를, 아니면 401(인증 필요) 을 돌려줍니다.
src/app/api/me/route.ts:
import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; // 로그인한 사용자만 호출할 수 있는 "내 API" 예시입니다. // 인증 방법 두 가지를 모두 지원합니다: // 1) 같은 사이트(브라우저) → 쿠키에 담긴 세션을 자동으로 사용 // 2) 외부 클라이언트(다른 서버/앱/CLI) → Authorization: Bearer <access_token> 헤더 export async function GET(request: Request) { const supabase = await createClient(); // Authorization 헤더가 있으면 그 토큰으로 검증하고, // 없으면 쿠키 세션으로 사용자를 확인합니다. const authHeader = request.headers.get("authorization"); const token = authHeader?.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : undefined; const { data: { user }, } = await supabase.auth.getUser(token); // 로그인하지 않았으면 401(인증 필요)을 돌려줍니다. if (!user) { return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); } // 여기부터는 "로그인한 사용자"가 확실하므로, 그 사람 전용 데이터를 돌려줍니다. return NextResponse.json({ id: user.id, email: user.email, provider: user.app_metadata.provider, message: `${user.email} 님, 인증된 API 호출에 성공했습니다!`, }); }
핵심은 단 한 줄입니다.
const { data: { user } } = await supabase.auth.getUser(token);
token이 있으면(외부 클라이언트의 Bearer 헤더) → 그 토큰을 검증합니다.token이 없으면(브라우저) →createClient()가 읽은 쿠키 세션으로 검증합니다.
createClient()는 지난 글에서 만든 서버용 Supabase 클라이언트(src/lib/supabase/server.ts)입니다. 쿠키를 읽어 세션을 복원합니다.
2. 시나리오 A — 브라우저에서 호출 (쿠키 자동 전송)
같은 사이트라면 아무것도 안 해도 됩니다. fetch("/api/me") 만 하면 브라우저가 로그인 쿠키를 자동으로 함께 보냅니다.
src/app/call-api-button.tsx:
"use client"; import { useState } from "react"; // 브라우저에서 우리 API(/api/me)를 호출하는 버튼입니다. // 로그인 세션 쿠키는 같은 사이트 요청이라 fetch 가 자동으로 함께 보냅니다. export function CallApiButton() { const [result, setResult] = useState<string | null>(null); const [loading, setLoading] = useState(false); async function callApi() { setLoading(true); const res = await fetch("/api/me"); // 쿠키 자동 첨부 const json = await res.json(); setResult(JSON.stringify(json, null, 2)); setLoading(false); } return ( <div className="space-y-2"> <button onClick={callApi} disabled={loading} className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 disabled:opacity-50" > {loading ? "호출 중..." : "내 정보 API 호출 (GET /api/me)"} </button> {result && ( <pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 text-xs text-green-300"> {result} </pre> )} </div> ); }
이 버튼을 보호된 홈 페이지(src/app/page.tsx)에 넣으면, 위 스크린샷처럼 로그인한 사용자의 정보가 JSON으로 표시됩니다.
import { CallApiButton } from "./call-api-button"; // ... 로그인 확인 후 ... <CallApiButton />
3. 시나리오 B — 외부 클라이언트에서 호출 (Bearer 토큰)
프론트엔드와 다른 서버, 모바일 앱, CLI 등에서 내 API를 부를 때는 쿠키가 자동으로 가지 않습니다. 이때는 access_token을 꺼내서 헤더에 직접 넣습니다.
토큰 꺼내기 (브라우저/클라이언트 측)
import { createClient } from "@/lib/supabase/client"; const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); const accessToken = session?.access_token; // 이게 Bearer 토큰 const res = await fetch("https://내-백엔드.com/api/me", { headers: { Authorization: `Bearer ${accessToken}` }, });
curl 로 호출해 보기
curl https://내-백엔드.com/api/me \ -H "Authorization: Bearer <ACCESS_TOKEN>"
서버(우리 /api/me)는 위 1번 코드의 getUser(token) 으로 그 토큰을 검증한 뒤, 통과하면 사용자 데이터를 돌려줍니다. 토큰이 없거나 가짜면 401 입니다.
💡 만료 주의: access_token 은 보통 1시간 뒤 만료됩니다. 만료되면
supabase.auth.getSession()이 자동으로 갱신된 토큰을 주므로, 요청 직전에 매번 토큰을 새로 꺼내 쓰는 것이 안전합니다.
4. 정말 동작할까? — E2E 테스트로 검증
말로만 "된다"고 하면 곤란하니, Playwright로 실제 서버를 띄워 네 가지를 자동 검증했습니다.
e2e/api.spec.ts (핵심):
// 1) 로그인 안 하면 401 test("로그인하지 않으면 /api/me 는 401 을 준다", async ({ request }) => { const res = await request.get("/api/me"); expect(res.status()).toBe(401); }); // 2) 로그인하면 Bearer / 쿠키 / UI 버튼 모두로 호출 가능 test("로그인하면 쿠키·Bearer·UI 버튼으로 /api/me 를 호출할 수 있다", async ({ page, context }) => { // ...관리자 권한으로 테스트 사용자 생성 후... // (1) Bearer 토큰으로 호출 const bearerRes = await api.get("/api/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); expect(bearerRes.status()).toBe(200); expect((await bearerRes.json()).email).toBe(email); // (2) 쿠키 세션으로 호출 const cookieRes = await context.request.get("/api/me"); expect(cookieRes.status()).toBe(200); // (3) 홈에서 버튼을 누르면 결과가 표시됨 await page.goto("/"); await page.getByRole("button", { name: /내 정보 API 호출/ }).click(); await expect(page.locator("pre")).toContainText(email); });
실행 결과 — 전부 통과 ✅
✓ 로그인하지 않으면 /api/me 는 401 을 준다 ✓ 로그인하면 쿠키·Bearer·UI 버튼으로 /api/me 를 호출할 수 있다
5. 실무 보안 체크리스트
- 신원 검증은 항상 서버에서
getUser()로.getSession()값만 믿고 권한을 판단하지 않습니다. - 인증 실패는 명확히 401 로 응답합니다.
- 권한(authorization) 은 또 다른 문제입니다. "로그인했는가"(인증)와 "이 데이터를 볼 자격이 있는가"(권한)는 다릅니다. DB 데이터라면 Supabase의 RLS(Row Level Security) 로 행 단위 접근을 통제하세요.
- access_token(JWT)은 민감 정보입니다. 로그(console.log)나 URL 쿼리스트링에 남기지 마세요. 항상 Authorization 헤더로 보냅니다.
- 프로덕션은 HTTPS 필수입니다.
마치며
소셜 로그인으로 끝이 아니라, 그 세션을 내 API까지 안전하게 이어주는 것이 진짜 시작입니다. 핵심은 두 가지였습니다.
- 내 API는 서버에서
getUser()로 토큰을 검증한다. - 같은 사이트는 쿠키가, 외부 클라이언트는 Bearer 헤더가 그 토큰을 운반한다.
이 패턴 하나면 웹 프론트엔드뿐 아니라 모바일 앱, 외부 서버, CLI 어디서든 "로그인한 사용자 전용 API"를 만들 수 있습니다. 즐거운 코딩 되세요! 🚀





댓글
댓글을 작성하려면 이 필요합니다.