S
Supabase2y ago
Igor

Issuing a JWT via SvelteKit API route throws `Cannot use `cookies.set(...)`...`

Cannot use cookies.set(...) after the response has been generated In my SvelteKit (1.29.1.) I have an API endpoint that's used by the companion Chrome extension. Extension grabs the cookie and sends it as a bearer in a fetch chain: - first to /api/refresh that's checking whether the token is still valid, resigns it if it's outdated, and returns it; - then /api/upload using the returned token attaches it again to the header, along with the body. However SvelteKit crashes right after the endpoint returns the new signed JWT. While the token is still valid (I set expiration to 60 while reproducing the bug) (see the screenshot). My hooks.server.ts implementation is based on the tutorials, minus the api route interception to catch the bearer. I'm clueless. Any advice please? :)
No description
No description
No description
No description
45 Replies
Igor
IgorOP2y ago
It sounds as if this issue persists in the ssr as well, but I haven't tried switching myself https://github.com/supabase/auth-helpers/issues/466#issuecomment-1833564866.
GitHub
[SvelteKit] Error: Cannot use cookies.set(...) after the response...
Bug report Describe the bug When using SvelteKit's new streaming promises feature, it seems to run into an issue setting cookies with the logic that runs in server hooks mentioned here. Here is...
Socal
Socal2y ago
So is the extension the one trying to set the cookie? I'm not sure I understand. The extension should not touch the cookie. It can instantiate the supabaseclient and just use the session jwt to query
Igor
IgorOP2y ago
No, sorry I probably wasn't clear. Extension only grabs the cookie and then sends a request the SvelteKit's endpoint that's handling the supabase request. The issue is that if user doesn't visit the actual web app to refresh the token and use the extension, server fails with invalid token something-something expired __ ago. So I tried implementing the refresh mechanism on the SvelteKit. First the extension sends the token from the cookie to the /refresh endpoint that checks if it's still valid (to avoid the 401 essentially). Signs new JWT and sends it back. Then cookie uses that JWT to send another request to /upload endpoint with the new token in the header. That's it
Socal
Socal2y ago
it's hard to read your screen shots, but i believe you are trying to instantiate the supabase client 2 times. 1 for the top level hook and 1 for /api routes. This is not correct.
Igor
IgorOP2y ago
Hmm, I thought what I'm doing is I'm reinitiating with the user's JWT if it hits /api
Socal
Socal2y ago
your /api route should have a server.ts file for that logic similar to how I do this to exchange a magic link code for a session
import { redirect, type Action } from '@sveltejs/kit';

export const GET: Action = async ({ url, locals: { supabase } }) => {
const code = url.searchParams.get('code');

if (code) {
await supabase.auth.exchangeCodeForSession(code);
}

throw redirect(303, '/app/dashboard');
};
import { redirect, type Action } from '@sveltejs/kit';

export const GET: Action = async ({ url, locals: { supabase } }) => {
const code = url.searchParams.get('code');

if (code) {
await supabase.auth.exchangeCodeForSession(code);
}

throw redirect(303, '/app/dashboard');
};
this is in my routes/api/auth/callback/server.ts
Igor
IgorOP2y ago
Right, but are you calling this route from inside the app or externally?
Socal
Socal2y ago
you can do either, it's an api end point
Igor
IgorOP2y ago
No I know, but if you're hitting it from the different origin, it doesn't have access to the bearer of the browser
Socal
Socal2y ago
+server.ts i mean. any +server files only run on the server and you can have them accessible via any rest call
Igor
IgorOP2y ago
so you need to attach an auth header
Socal
Socal2y ago
pass the auth header in your request and it would or pass it as a param it's not like jwt are super sensitive
Igor
IgorOP2y ago
But how are you going to initiate the supabase client with it if you're already hitting the endpoint? :) export const GET: Action = async ({ url, locals: { supabase } }) => { here you're accessing supabase from locals but it relies on the session cookie Your endpoint could read the auth header from the request, sure, but at the time your supabase is accessible in the locals, it already has to have an authorization, otherwise supabase wouldn't be initialized and available in locals. Unless you can change the header of the supabase init inside the server endpoint, which I'm not sure is possible? hooks.server.ts
if (event.url.pathname.startsWith('/api')) {
let bearer = event.request.headers.get('Authorization')

// In order for supabase-js calls to come through
// instead of initializing it with anon key,
// we initialize with the user's bearer jwt / access_token
if (bearer) {
// console.log('hooks bearer:', bearer)

event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => event.cookies.get(key),
set: (key, value, options) => {
event.cookies.set(key, value, options)
},
remove: (key, options) => {
event.cookies.delete(key, options)
}
},
global: {
headers: {
Authorization: bearer
}
}
})
}
if (event.url.pathname.startsWith('/api')) {
let bearer = event.request.headers.get('Authorization')

// In order for supabase-js calls to come through
// instead of initializing it with anon key,
// we initialize with the user's bearer jwt / access_token
if (bearer) {
// console.log('hooks bearer:', bearer)

event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => event.cookies.get(key),
set: (key, value, options) => {
event.cookies.set(key, value, options)
},
remove: (key, options) => {
event.cookies.delete(key, options)
}
},
global: {
headers: {
Authorization: bearer
}
}
})
}
that part is cut in 2 in my screenshots, so just to make sure we're on the same page this essentially replaces the supabase in the locals with the bearer in the headers
garyaustin
garyaustin2y ago
If you are setting your own authorization header just us supabase-js client. Don't involve auth-helpers at all for that particular call. This sort of goes back to just minting your own jwt from the one you get so it does not expire though.
Igor
IgorOP2y ago
So basically just do https://supabase.com/docs/reference/javascript/initializing?example=with-additional-parameters in each of the api endpoints instead of using supabase from locals because they're created there by the createServerClient, which is the auth-helper, sorta? Sorry, @Socal, is this what you meant? I might have misunderstood you
Socal
Socal2y ago
i didn't fully flesh this out. essentially your api endpoint to refresh your token would be something along these lines
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import { createClient } from '@supabase/supabase-js';

// +server.ts

export const GET: Action = async ({ request }) => {
const authHeader = request.headers.get('Authorization')!;
const supabase = createClient(
PUBLIC_SUPABASE_ANON_KEY,
PUBLIC_SUPABASE_URL,
{ global: { headers: { Authorization: authHeader } } }
);

const session = supabase.auth.refreshSession().then((newSession) => {
return newSession
});

return Response(session)
};
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import { createClient } from '@supabase/supabase-js';

// +server.ts

export const GET: Action = async ({ request }) => {
const authHeader = request.headers.get('Authorization')!;
const supabase = createClient(
PUBLIC_SUPABASE_ANON_KEY,
PUBLIC_SUPABASE_URL,
{ global: { headers: { Authorization: authHeader } } }
);

const session = supabase.auth.refreshSession().then((newSession) => {
return newSession
});

return Response(session)
};
Socal
Socal2y ago
Supabase JWT expiration issue
Troubleshoot and resolve JWT expiration in Supabase. Understand token lifecycle for secure access.
Igor
IgorOP2y ago
Ohhh, I see now... Let me try this!
Socal
Socal2y ago
you're just trying to get a new session back into your chrome extention. it doesn't make sense to have it tied up into hooks because you're not using that all over your app
Igor
IgorOP2y ago
I'm half-way there, but just to make sure, it looks like whatever uses the endpoint would also need to pass in the refresh_token, along with the access_token, otherwise the refershSession() wouldn't work, right? refreshSession() tries to getSession() if no refresh_token is passed in, and it won't find any inside the endpoint. Or another option is to return a freshly minted JWT if jwt.verify() returns TokenExpiredError, which seems a bit nuclear to me, if there's a refersh method available?
garyaustin
garyaustin2y ago
There is not a method to refresh without burning the refresh_token which then kills your other clients using it.
Igor
IgorOP2y ago
Oh right, you mentioned this... So newly minted JWT is the only way then I wish it said that here though https://supabase.com/docs/reference/javascript/auth-refreshsession?example=refresh-session-using-a-passed-in-session. It just says it's going to return one maybe it's logical, I'm just too noob for it lolol
garyaustin
garyaustin2y ago
Yeah. The jwt auth section discusses how refresh tokens work with the jwt (this is not a Supabase specific thing). It is for security of the jwt so if someone gets hold of them you can kill the refresh token with a signout so they can't keep refreshing the jwt. If you could keep using the same refresh token over and over then you have no way to stop it.
garyaustin
garyaustin2y ago
Feel like you are going thru the wringer on this... But if you go the mint jwt route this call is useful https://supabase.com/docs/reference/javascript/auth-admin-getuserbyid to get the current claims you want to mint based on having the user id.
Igor
IgorOP2y ago
Currently trying to understand how a newly minted JWT becomes instantly expired when used, even though I'm setting the new expiresIn:
export const initSupabase = async (bearer: string) => {
let supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${bearer}`
}
}
})

// Check if the JWT is expired
let payload

try {
payload = jwt.verify(bearer, PRIVATE_SUPABASE_JWT_SECRET) as JwtPayload
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('supabase init caught expired token, resigning')

// If it's expired, extract the jwt payload
payload = jwt.decode(bearer) as JwtPayload

// omit system data from the token that we cannot control
const { exp, iat, amr, session_id, ...rest } = payload

// sign the new token
const newToken = jwt.sign(rest, PRIVATE_SUPABASE_JWT_SECRET, { expiresIn: '1hr' })

// init the supabase with the new token now
supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${newToken}`
}
}
})
}
}

return supabase
}
export const initSupabase = async (bearer: string) => {
let supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${bearer}`
}
}
})

// Check if the JWT is expired
let payload

try {
payload = jwt.verify(bearer, PRIVATE_SUPABASE_JWT_SECRET) as JwtPayload
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('supabase init caught expired token, resigning')

// If it's expired, extract the jwt payload
payload = jwt.decode(bearer) as JwtPayload

// omit system data from the token that we cannot control
const { exp, iat, amr, session_id, ...rest } = payload

// sign the new token
const newToken = jwt.sign(rest, PRIVATE_SUPABASE_JWT_SECRET, { expiresIn: '1hr' })

// init the supabase with the new token now
supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${newToken}`
}
}
})
}
}

return supabase
}
Igor
IgorOP2y ago
No description
No description
garyaustin
garyaustin2y ago
Are you sure it is 1hr and not 1h. Not sure which library you are using. Also, what are you doing in this code as far as hitting the database/storage etc? Unless you need to meet RLS you can also just user service_role key and query using the user_id directly on tables. Then you don't mess with the jwt at all.
Igor
IgorOP2y ago
jsonwebtoken. Pff you're right, noticed it just now too. The endpoint: - uploads file(s) to storage into user's folder. That triggers some pgsql functions; - generates publicUrls fo the files; - then it looks for the newly created records (by the function) and updates some columns. Unsure really if I could skip the RLS here? they do need to have access to the storage/tables
garyaustin
garyaustin2y ago
Yeah, I think that needs the jwt to fill in the owner column in storage and also if you do any triggers with auth.uid() or defaults.
Igor
IgorOP2y ago
So I managed to set everything up with the JWT issuance logic correctly and extension works fine. But now I have a broader API/auth architecture question. What if the same API endpoints are used by both the web app and the external clients? Issuing a JWT for every external client request based on the cookie might be fine, but if user is inside the web app and +page.server.ts are making requests to +server.ts endpoints, I'd want this to be handled with auth-helpers (supabase/ssr), wouldn't I? Trying to figure this out 🤔
Socal
Socal2y ago
yes, you typically want to use the auth helper library as it makes passing the supabase instance around your app much easier using locals.
Igor
IgorOP2y ago
Thank you! Do you think I should set up some sort of a origin check in the hooks? Say if the api request coming from the same origin, then initiate with local session, otherwise look for bearer?
Socal
Socal2y ago
i would think the sveltekit like way would be to segragate out at the root route level folders for (extention) (app) where any end points under the (extention) folder handle the request logic one way and anything under the (app) folder handles it a different way
Igor
IgorOP2y ago
Hmm, I see. I sort of do that already, but for me it won't be just the (extension), it's a general api that's to be used by other clients as well. The query logic is the same, as to avoid duplication I thought general authorization might be a good idea
Socal
Socal2y ago
you're the dev, set it up however you want
Igor
IgorOP2y ago
Thanks for all the advice! You guys are awesome! Deployed the entire mechanism and the extension works fine, but now I'm not meeting some RLS conditions on the web. For example private stuff that's supposed to be (auth.uid() = user_id) is not visible even for me as the user and owner of the stuff. Idk if anything changed in supabase/ssr drastically (doesn't look like it), but I suspect it's a conflict of different supabase clients trying to reuse the same cookie for API (createClient) and web pages (createServerClient, createBrowserClient).
garyaustin
garyaustin2y ago
Supabase.js clients do not use cookies. Only auth-helpers/ssr ones do.
Igor
IgorOP2y ago
100%. This is what I do briefly:
// hooks.server.ts
const { supabase, token } = await initSupabase(bearer, origin, event.cookies)
event.locals.supabase = supabase
// hooks.server.ts
const { supabase, token } = await initSupabase(bearer, origin, event.cookies)
event.locals.supabase = supabase
// initSupabase.ts
if (notSameOriginAsAPI) {
supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${token}`
}
}
})
} else {
supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => cookies.get(key),
set: (key, value, options) => cookies.set(key, value, options),
remove: (key, options) => cookies.delete(key, options)
}
})
}
// initSupabase.ts
if (notSameOriginAsAPI) {
supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${token}`
}
}
})
} else {
supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => cookies.get(key),
set: (key, value, options) => cookies.set(key, value, options),
remove: (key, options) => cookies.delete(key, options)
}
})
}
So in all cases hooks supplies supabase. It's just a different initiation. Then for example layout page (where I'm having RLS issues) does this:
const response = await fetch(`${API_URL}/api/profiles/${params.handle}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session?.access_token || ''}`
}
})
const response = await fetch(`${API_URL}/api/profiles/${params.handle}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session?.access_token || ''}`
}
})
And then profile endpoint just runs the supabase query. But my layout.ts which is a browser env is left as is here https://supabase.com/docs/guides/auth/server-side/creating-a-client?framework=sveltekit&environment=layout.
garyaustin
garyaustin2y ago
So you should set persistSession:false in that first client, just in case. I don't think local storage can be involved as you are using a serverclient.
Igor
IgorOP2y ago
Even tried like this, no luck
No description
garyaustin
garyaustin2y ago
I would always do that on any client you are not using user sessions for just as default.
Igor
IgorOP2y ago
The more I work on this, the more I feel like hosting API in the same SvelteKit is not the best idea. I'm planning on iOS and macOS apps, as well as Chrome extension (already in flight). So that's why I thought I needed API. But after spending 3 days on just rewriting API authorization logic, I feel like initiating supabase inside each client could've been faster 🤷‍♂️ The biggest downside is writing repetitive queries with supabase clients which essentially are going to query the same thing. Experience I guess!

Did you find this page helpful?