내 ID가 왜 달라요? (auth.uid() vs user_id)
1. "분명히 로그인했는데 ID가 없대요."
RLS(Row Level Security) 정책이나 트리거(Trigger)를 짤 때 가장 많이 쓰는 함수가 auth.uid()입니다.
그런데 이 함수가 가끔 에러를 뱉거나 null을 리턴해서 사람을 미치게 합니다.
-- ❌ DB 툴(DBeaver 등)에서 실행하면 에러: function auth.uid() does not exist
SELECT auth.uid();
특히 Database Function을 만들거나 외부 도구(TablePlus)에서 테스트할 때 자주 발생합니다.
2. 원리 이해 - Request Context (요청 문맥)
auth.uid()는 일반적인 PostgreSQL 내장 함수가 아닙니다.
Supabase(정확히는 PostgREST)가 API 요청을 받을 때, 헤더에 있는 JWT 토큰을 해석해서 잠시 메모리에 심어두는 값(request.jwt.claim.sub)을 꺼내오는 래퍼(Wrapper) 함수입니다.
즉, API를 통하지 않고 직접 DB에 접속하거나, 토큰 없이 요청하면 auth.uid()는 작동하지 않거나 null을 반환합니다.
3. 문제 1 - 트리거(Trigger)에서 사용 시 주의점
BEFORE INSERT 트리거에서 "누가 글을 썼는지" 자동으로 기록하고 싶습니다.
CREATE FUNCTION handle_new_post() RETURNS TRIGGER AS $$
BEGIN
-- ⚠️ 위험: 관리자 모드나 Batch 작업에선 터질 수 있음
NEW.user_id := auth.uid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
API로 호출할 땐 잘 되지만, 나중에 관리자 페이지에서 데이터를 일괄 입력(Bulk Insert)할 때 auth.uid()가 null이라서 에러가 터집니다(Not Null 제약조건 위반).
해결책: 방어 로직을 넣어야 합니다.
BEGIN
-- user_id가 비어있을 때만 채워줌 (관리자가 직접 넣으면 그거 씀)
IF NEW.user_id IS NULL THEN
NEW.user_id := auth.uid();
END IF;
RETURN NEW;
END;
4. 문제 2 - RLS 정책 (Row Level Security)
RLS에서 auth.uid()는 필수입니다.
"내 글은 나만 볼 수 있다"를 구현하려면 이렇게 씁니다.
-- ✅ SELECT 정책 (내가 쓴 글만 보임)
create policy "Individuals can view their own posts"
on posts for select
using ( auth.uid() = user_id );
하지만 여기서 실수하는 게, Insert 정책입니다.
"내 아이디로만 글을 쓸 수 있다"를 강제해야 합니다.
-- ✅ INSERT 정책 (내 아이디로만 생성 가능)
create policy "Individuals can create posts"
on posts for insert
with check ( auth.uid() = user_id );
USING은 기존 행을 조회/수정할 때, WITH CHECK는 새로운 행을 넣을 때 검사합니다.
5. Security Definer (권한 우회) 파헤치기
Supabase의 유저 정보는 auth라는 별도 스키마(auth.users)에 있습니다.
하지만 우리는 보통 public 스키마에 profiles 테이블을 만들어서 씁니다.
이때 함수 내부에서 auth.users를 조회하려고 하면 권한 에러가 납니다. (일반 유저는 auth 스키마 접근 불가)
해결책: security definer 옵션을 사용하세요.
이 옵션을 주면, 함수가 실행되는 동안만 함수 생성자(Superuser)의 권한을 빌려씁니다.
-- 유저 가입 시 자동으로 profiles에도 row 생성
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$ language plpgsql security definer; -- 👈 핵심!
주의: security definer는 "양날의 검"입니다. 아무나 이 함수를 호출해서 관리자 권한을 쓸 수 없도록, RLS나 권한 부여(GRANT EXECUTE)를 잘 해야 합니다.
6. auth.jwt() 활용 깊이 들여다보기
단순 ID 말고, 유저의 메타데이터(예: is_admin 같은 Custom Claims)가 필요하다면?
auth.jwt() 함수를 쓰면 토큰 전체를 JSON으로 받을 수 있습니다.
-- 관리자(admin) 그룹인지 확인
select (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin';
이걸 활용하면 RLS 정책에서 "관리자는 모든 글을 볼 수 있다" 같은 로직도 짤 수 있습니다.
7. 한 줄 요약
auth.uid()는 API 요청 문맥(Context) 안에서만 산다. 트리거에선 null 체크를 필수적으로 하고, 권한이 더 필요하면 security definer를 써라.
8. Service Role Key의 위험성 제대로 이해하기
Supabase 설정을 보면 anon 키와 service_role 키 두 가지가 있습니다.
- anon: 공개 키. 클라이언트(프론트엔드)에서 씁니다. RLS의 영향을 받습니다.
- service_role: 비밀 키. 서버(백엔드)에서 씁니다. RLS를 무시합니다 (Admin 권한).
가끔 "에러가 나서 귀찮다"는 이유로 프론트엔드 코드에 service_role 키를 넣는 분들이 계십니다.
절대 안 됩니다. 해커가 그 키를 보면 DB의 모든 데이터를(심지어 auth.users의 암호화된 비밀번호 해시까지) 덤프 떠갈 수 있습니다.
service_role 키는 오직 Edge Functions나 백엔드 서버에서만 은밀하게 사용하세요.
9. 핵심 용어 정리
- JWT (JSON Web Token): Supabase의 Access Token입니다. 유저의 ID(
sub)와 유효기간(exp), 메타데이터가 들어있습니다. - RLS (Row Level Security): "행(Row) 수준 보안". 테이블 전체가 아니라, 데이터 한 줄 한 줄마다 "누가 볼 수 있는지" 검사하는 방화벽입니다.
- Context (문맥): 쿼리가 실행되는 환경입니다. API 요청은 "User Context"를 가지고, 직접 DB 접속은 "Superuser Context"를 가집니다.
- Security Definer: 함수의 실행 권한을 "함수를 만든 사람(Admin)"으로 승격시키는 옵션입니다. 리눅스의
sudo와 같습니다. - Claims: JWT 안에 들어있는 정보 조각들입니다.
app_metadata에role: admin같은 정보를 심어서 권한 관리를 할 수 있습니다.
Supabase: Understanding auth.uid() function and RLS
1. "Why is My ID Different?"
For most developers coming from Firebase, Supabase's auth.uid() feels familiar but behaves strangely in edge cases.
You might see errors like:
function auth.uid() does not existnull value in column "user_id" violates not-null constraint
These errors happen because auth.uid() is NOT a static value. It is a dynamic value extracted from the request context.
2. The Architecture: How PostgREST works
To understand the error, you must understand the architecture. Supabase uses PostgREST to turn your PostgreSQL database into a REST API.
- Client sends HTTP Request with
Authorization: Bearer <JWT_TOKEN>. - PostgREST receives the request.
- It validates the JWT.
- It sets a configuration variable in the transaction:
set_config('request.jwt.claim.sub', 'user_uuid', true). - PostgreSQL executes the query.
auth.uid()acts as a getter for that configuration variable.
The implication:
If you connect to the DB directly (via port 5432) using postgres user, there is no HTTP request. Therefore, there is no JWT, and auth.uid() returns NULL.
3. Problem 1: Database Triggers (The Silent Killer)
Triggers are powerful. You want to automatically assign ownership to every new row.
CREATE FUNCTION assign_owner() RETURNS TRIGGER AS $$
BEGIN
NEW.user_id := auth.uid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_create_post
BEFORE INSERT ON posts
FOR EACH ROW EXECUTE FUNCTION assign_owner();
Scenario A (User): A user creates a post via valid API. auth.uid() is 123. NEW.user_id becomes 123. Success.
Scenario B (Admin): You want to seed the database or fix some data using the SQL Editor or a script. You run INSERT INTO posts.... There is no JWT. auth.uid() is NULL. The INSERT fails.
The Fix: Always program defensively.
BEGIN
-- Allow manual override. If I provide a user_id, respect it.
-- If I don't provide one (NULL), try to get it from auth.uid().
IF NEW.user_id IS NULL THEN
NEW.user_id := auth.uid();
END IF;
-- Validation (Optional)
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'User ID is required';
END IF;
RETURN NEW;
END;
4. Problem 2: RLS Policies (The Gatekeeper)
Row Level Security (RLS) is the firewall of your database.
Writing policies often involves auth.uid().
The SELECT Policy
"Users can see their own data."
CREATE POLICY "Select Own Data" ON posts
FOR SELECT
USING (auth.uid() = user_id);
The INSERT Policy (Common Mistake)
"Users can create their own data." Many developers blindly copy the SELECT policy. But for INSERT, you need TWO checks:
- WITH CHECK: Does the new row satisfy the condition?
CREATE POLICY "Insert Own Data" ON posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);
If you use USING for INSERT, it does nothing. You must use WITH CHECK.
Ideally, you should enable PL/pgSQL Security Definer functions if the logic gets complex, but for simple ownership, the policy above is standard.
5. Deep Dive 1: Security Definer (Sudo Mode)
Sometimes you need to break the rules.
For example, when a user signs up, you want to create a Profile row.
But standard users might not have permission to INSERT into the profiles table directly, or auth.users table is globally unaccessible.
Solution: Use security definer.
This tells Postgres: "Run this function with the privileges of the User who defined/created the function (usually the superuser/admin), NOT the user who is calling it."
It is equivalent to sudo in Linux.
-- Trigger on auth.users (System table)
-- When a user is created in Auth, create a profile in Public
create function public.on_auth_user_created()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer; -- 👈 Run as Admin!
Warning: Since this runs as Admin, RLS is bypassed. Be very careful.
If you make a function delete_user(id) with security definer, ANY logged-in user could call it and delete anyone if you don't add checks inside the function (IF auth.uid() != id THEN RAISE...).
6. Deep Dive 2: auth.jwt() vs auth.uid()
auth.uid() returns a simple UUID.
auth.jwt() returns the entire JSON object of the token.
This is incredible for Role Based Access Control (RBAC).
Instead of querying a separate user_roles table (which is slow and complex in RLS), you can embed the role inside the JWT App Metadata.
Example:
- Set
app_metadata: { "role": "admin" }in the user's Auth object (server-side). - In RLS:
create policy "Admins can do anything"
on posts
for all
using (
auth.jwt() -> 'app_metadata' ->> 'role' = 'admin'
);
This is lightning fast because it requires zero DB joins. The data is already in the memory (the token).
7. Deep Dive Glossary
- JWT (JSON Web Token): Specifically
Access Tokenin Supabase. It contains user identity (sub), expiration (exp), and metadata (app_metadata). - RLS (Row Level Security): A Postgres feature that filters data per row based on the user performing the query. It's the firewall of your database.
- Context: The environment in which a query runs. API calls have a User Context (JWT). Direct DB connections have a Superuser/Service Context.
- Security Definer: A mode for Postgres functions where the function runs with the privileges of the creator (usually admin), not the caller. Equivalent to
sudoin Linux. - Security Invoker: The default mode. The function runs with the privileges of the caller. If the caller can't read
auth.users, the function can't either. - PostgREST: The web server that turns your Postgres database directly into a REST API. It handles the JWT verification and exposes
auth.uid(). - Claims: Key-value pairs inside the JWT. Standard claims include
sub(subject/user_id),iss(issuer),exp(expiry). Custom claims can holdrole,plan_type, etc.