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.

Gleisson MatosFounder & CEO4 min read
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.

Add authentication to my Nulvless Edge Function.

Follow these pages. They document the real API, so do not guess method names:
- https://www.nulvless.com/llms.mdx/blog/authenticate-edge-functions/content.md
- https://www.nulvless.com/llms.mdx/docs/frameworks/node/content.md
- https://www.nulvless.com/llms.mdx/docs/sdk/auth/content.md

Requirements:
- Use createServerClient from npm:@nulvless/nulvless-js with NULVLESS_URL and PROJECT_SECRET_KEY read through Deno.env.get. NULVLESS_URL is injected automatically. Never hard-code the secret key.
- Do not name any secret with a NULVLESS_ or NVL_ prefix. Those names are reserved.
- Export default { fetch(req) } and verify the caller with auth.verify, forwarding the Authorization header.
- Return the error status_code from verify (401 or 403) instead of running the handler.
- Require the grant I describe with resource and scopes or roles in the first argument of verify.
- For destructive actions, also require data.session.sudo to be true.
- In the browser, send the token from nulvless.auth.getAccessToken() as Authorization: Bearer <token>.

Ask me which resource, scopes or roles the function should require before you start.
or open in

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:

KeyValue
PROJECT_SECRET_KEYThe 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:

index.ts
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.

index.ts
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_KEY from Deno.env, and the sk- 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_code from 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.

Keep reading

Engineering5 min read

Introducing Edge Functions on the Nulvless Network

What edge functions are, how they run your TypeScript in the region closest to each user, and when to use them instead of single-region serverless.

Gleisson Matos