Authenticate users in edge functions with Nulvless
Verify sessions, require roles and scopes, and ask for sudo before destructive actions, in a TypeScript edge function on the Nulvless Network.
On this page
Every edge function has a public URL. That is what makes it useful, and also
what makes it dangerous: anyone who finds
https://<project>.nulvless.co/functions/<slug> can call it. For anything that
reads or changes a user's data, the function has to answer two questions before
it does any work: who is calling, and are they allowed to do this.
This guide builds that check into a Nulvless Edge Function with
@nulvless/nulvless-js, from a plain session check to roles, scopes and sudo
mode. If edge functions are new to you, start with
Introducing Edge Functions.
Build this with your agent
A ready prompt for Claude Code, Cursor, ChatGPT or any coding agent. It points the agent at this guide and the docs, so it follows the real API instead of guessing.
How the check works
The browser already holds an access token for the signed-in user. It sends that
token to your function in the Authorization header. The function passes it to
auth.verify, which answers both questions in one call: the token is valid and
not revoked, and, if you ask, the user holds a given role or scope on a given
resource.
Because the identity service runs on the same Nulvless Network as your function, that call does not leave the region the request arrived in.
1. Add the secret key
A function that verifies tokens uses the secret key of your project, which must never reach the browser. Open the function in the console, go to Secrets, and add it:
| Key | Value |
|---|---|
PROJECT_SECRET_KEY | The sk- key of your production environment, main |
You do not need to add the project URL. Every function already receives it as
NULVLESS_URL, along with NULVLESS_FUNCTION_SLUG and
NULVLESS_FUNCTION_VERSION. Those names, and any other that starts with
NULVLESS_ or NVL_, are reserved, so the console rejects a secret that uses
them.
NULVLESS_URL always points to production, because a function belongs to the
project, not to one environment. That is why the key comes from main: keys
belong to an environment, and a token issued by develop is not accepted by
main.
2. Verify the session
Create the server client once, outside the handler, so it is reused across requests handled by the same isolate:
import { createServerClient } from 'npm:@nulvless/[email protected]';
const nulvless = createServerClient(
Deno.env.get('NULVLESS_URL')!,
Deno.env.get('PROJECT_SECRET_KEY')!,
);
export default {
async fetch(req: Request) {
const { data, error } = await nulvless.auth.verify(undefined, {
headers: { Authorization: req.headers.get('Authorization') ?? '' },
});
if (error) {
return Response.json({ error: error.code }, { status: error.status_code });
}
return Response.json({ user: data.user.id });
},
};Passing undefined as the first argument checks the token only. A missing,
expired or revoked token comes back as an error with a status_code, usually
401, which you can return as it is.
Like every method in the SDK, verify returns { data, error } instead of
throwing, so there is no try to forget.
3. Require a role or a scope
Most endpoints need more than "signed in". To require a permission, name it in the first argument. The check runs on the same call:
const { data, error } = await nulvless.auth.verify(
{ resource: orgId, scopes: ['reports:export'] },
{ headers: { Authorization: req.headers.get('Authorization') ?? '' } },
);resource is the context the grant applies to, such as an organization or a
project of yours. You can ask for scopes, roles, or both. A user who is
signed in but lacks the grant gets a 403, and your handler never runs.
Let verify make this decision. Fetching the user and comparing roles yourself
works until the rules change, and then it is wrong in every function that
copied it.
4. Ask for sudo before destructive actions
Some actions deserve more than a valid session: deleting a workspace,
rotating an API key, removing a member. For those, verify also tells you
whether the session is in sudo mode, meaning the user re-proved who they
are moments ago:
if (!data.session.sudo) {
return Response.json({ error: 'sudo_required' }, { status: 403 });
}The browser reacts by asking for a code and retrying. How that flow works on the client is covered in Sudo mode: asking again before the dangerous things.
The complete function
Putting it together: an endpoint that deletes a project, only for owners of the organization, and only with sudo active.
import { createServerClient } from 'npm:@nulvless/[email protected]';
const nulvless = createServerClient(
Deno.env.get('NULVLESS_URL')!,
Deno.env.get('PROJECT_SECRET_KEY')!,
);
export default {
async fetch(req: Request) {
if (req.method !== 'DELETE') {
return new Response(null, { status: 405 });
}
const url = new URL(req.url);
const orgId = url.searchParams.get('org');
const projectId = url.searchParams.get('project');
if (!orgId || !projectId) {
return Response.json({ error: 'org_and_project_required' }, { status: 400 });
}
const { data, error } = await nulvless.auth.verify(
{ resource: orgId, roles: ['owner'] },
{ headers: { Authorization: req.headers.get('Authorization') ?? '' } },
);
if (error) {
return Response.json({ error: error.code }, { status: error.status_code });
}
if (!data.session.sudo) {
return Response.json({ error: 'sudo_required' }, { status: 403 });
}
await deleteProject(orgId, projectId, data.user.id);
return new Response(null, { status: 204 });
},
};deleteProject is your own code, calling your database or another API with
secrets read from Deno.env. By the time it runs, three things are already
true: the token is valid, the caller owns the organization, and they proved
their identity again in the last few minutes.
5. Call it from the browser
On the client, take the current token from the SDK and send it along. The SDK renews the token when it is close to expiring, so ask for it right before the request instead of caching it:
const token = await nulvless.auth.getAccessToken();
const response = await fetch(
`https://acme.nulvless.co/functions/projects?org=${orgId}&project=${projectId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.status === 403) {
const { error } = await response.json();
if (error === 'sudo_required') openSudoPrompt();
}What to check before you ship
- The function reads
PROJECT_SECRET_KEYfromDeno.env, and thesk-key appears nowhere in your frontend or your repository. - Every branch that touches data runs after
verify, not before. - Permissions are named in
verify, not reimplemented by hand. - Destructive actions also check
data.session.sudo. - The function returns the
status_codefrom the SDK, so the client can tell "sign in again" (401) from "you are not allowed" (403).
For the full list of options verify accepts, see the
auth reference, and for how roles, scopes and resources fit
together, the authorization guide.

