Documentation
OAuth ServerConsent screen

Consent screen

The page in your application where a user approves a third-party app, built in Next.js, Nuxt, SvelteKit and Remix.

The consent screen is the one part of the OAuth flow you build yourself. nulvless does not render it, and that is deliberate: at the moment someone grants another company access to their account, they should be looking at your product.

It lives at the Authorization path you set in the Console, inside your Site URL. Users arrive with one query parameter:

https://yourapp.com/oauth/consent?authorization_id=auth_…

What the page has to do

Four things, in order.

Make sure someone is signed in

The authorization endpoints act on behalf of a session. If the visitor is signed out, send them to your login page and bring them back with the authorization_id intact — losing it strands them mid-flow with no way forward.

Read the request

getAuthorization(id) returns the client, the scopes and whether this was already approved. Everything you render comes from here. Do not read the client name from your own query string; it did not come from the client, and trusting it is how consent screens get spoofed.

Skip yourself when you can

If autoApproved is true, this user already granted these exact scopes to this client. Approve and redirect without rendering. A consent screen that reappears for permissions already given teaches people to click through without reading.

Record the answer and redirect where you are told

consent(id, 'approve' | 'deny') returns redirect.uri. Send the browser there. Do not construct that URL — it carries the code and the state, and assembling it yourself is how they get lost or leaked.

Next.js — App Router

Server component to read, server action to answer.

app/oauth/consent/page.tsx
import { redirect } from 'next/navigation';
import { createServerClient, forwardUserHeaders } from '@nulvless/nulvless-js';
import { headers } from 'next/headers';

export default async function ConsentPage({
  searchParams,
}: {
  searchParams: Promise<{ authorization_id?: string }>;
}) {
  const { authorization_id: id } = await searchParams;
  if (!id) redirect('/');

  const nulvless = createServerClient(process.env.NULVLESS_URL!, process.env.NULVLESS_KEY!);
  const { data, error } = await nulvless.auth.oauth.getAuthorization(id, {
    headers: forwardUserHeaders(new Request('https://x', { headers: await headers() })),
  });

  if (error) redirect(`/login?next=/oauth/consent?authorization_id=${id}`);
  if (data.autoApproved) redirect((await decide(id, 'approve')) as string);

  return (
    <main>
      <h1>{data.client.name} wants access to your account</h1>
      <ul>
        {data.scope.map((scope) => (
          <li key={scope}>{scopeLabel(scope)}</li>
        ))}
      </ul>
      <form action={approve}><button type="submit">Allow</button></form>
      <form action={deny}><button type="submit">Deny</button></form>
    </main>
  );

  async function approve() { 'use server'; redirect(await decide(id!, 'approve')); }
  async function deny() { 'use server'; redirect(await decide(id!, 'deny')); }
}

async function decide(id: string, action: 'approve' | 'deny') {
  'use server';
  const nulvless = createServerClient(process.env.NULVLESS_URL!, process.env.NULVLESS_KEY!);
  const { data } = await nulvless.auth.oauth.consent(id, action);
  return data.redirect.uri;
}

Nuxt

pages/oauth/consent.vue
<script setup lang="ts">
const route = useRoute();
const id = route.query.authorization_id as string;

if (!id) await navigateTo('/');

const { data, error } = await useAsyncData(`consent-${id}`, () =>
  $fetch(`/api/oauth/authorizations/${id}`),
);

if (error.value) {
  await navigateTo(`/login?next=${encodeURIComponent(route.fullPath)}`);
} else if (data.value?.autoApproved) {
  await decide('approve');
}

async function decide(action: 'approve' | 'deny') {
  const result = await $fetch<{ redirect: { uri: string } }>(
    `/api/oauth/authorizations/${id}/consent`,
    { method: 'POST', body: { action } },
  );

  await navigateTo(result.redirect.uri, { external: true });
}
</script>

<template>
  <main v-if="data">
    <h1>{{ data.client.name }} wants access to your account</h1>
    <ul>
      <li v-for="scope in data.scope" :key="scope">{{ scopeLabel(scope) }}</li>
    </ul>
    <button @click="decide('approve')">Allow</button>
    <button @click="decide('deny')">Deny</button>
  </main>
</template>

external: true matters

navigateTo defaults to client-side routing. The redirect URI belongs to the third-party app, so it has to leave your application — without external: true Nuxt will try to resolve it as one of your own routes and the flow dead-ends.

SvelteKit

Loader reads, form action answers.

src/routes/oauth/consent/+page.server.ts
import { redirect, error } from '@sveltejs/kit';
import { nulvless } from '$lib/server/nulvless';

export async function load({ url, locals }) {
  const id = url.searchParams.get('authorization_id');
  if (!id) redirect(303, '/');
  if (!locals.session) redirect(303, `/login?next=${encodeURIComponent(url.pathname + url.search)}`);

  const { data, error: failure } = await nulvless.auth.oauth.getAuthorization(id);
  if (failure) error(400, failure.message);

  if (data.autoApproved) {
    const { data: result } = await nulvless.auth.oauth.consent(id, 'approve');
    redirect(303, result.redirect.uri);
  }

  return { id, client: data.client, scope: data.scope };
}

export const actions = {
  decide: async ({ request, url }) => {
    const id = url.searchParams.get('authorization_id')!;
    const action = (await request.formData()).get('action') as 'approve' | 'deny';

    const { data } = await nulvless.auth.oauth.consent(id, action);
    redirect(303, data.redirect.uri);
  },
};
src/routes/oauth/consent/+page.svelte
<script lang="ts">
  let { data } = $props();
</script>

<main>
  <h1>{data.client.name} wants access to your account</h1>
  <ul>
    {#each data.scope as scope}<li>{scopeLabel(scope)}</li>{/each}
  </ul>
  <form method="POST" action="?/decide">
    <button name="action" value="approve">Allow</button>
    <button name="action" value="deny">Deny</button>
  </form>
</main>

Remix / React Router

app/routes/oauth.consent.tsx
import { redirect, Form, useLoaderData } from 'react-router';
import { nulvless } from '~/lib/nulvless.server';

export async function loader({ request }: { request: Request }) {
  const url = new URL(request.url);
  const id = url.searchParams.get('authorization_id');
  if (!id) throw redirect('/');

  const { data, error } = await nulvless.auth.oauth.getAuthorization(id);
  if (error) throw redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`);

  if (data.autoApproved) {
    const { data: result } = await nulvless.auth.oauth.consent(id, 'approve');
    throw redirect(result.redirect.uri);
  }

  return { id, client: data.client, scope: data.scope };
}

export async function action({ request }: { request: Request }) {
  const form = await request.formData();
  const id = new URL(request.url).searchParams.get('authorization_id')!;

  const { data } = await nulvless.auth.oauth.consent(
    id,
    form.get('action') as 'approve' | 'deny',
  );

  return redirect(data.redirect.uri);
}

export default function Consent() {
  const { client, scope } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>{client.name} wants access to your account</h1>
      <ul>{scope.map((s) => <li key={s}>{scopeLabel(s)}</li>)}</ul>
      <Form method="post">
        <button name="action" value="approve">Allow</button>
        <button name="action" value="deny">Deny</button>
      </Form>
    </main>
  );
}

Writing the screen itself

The code is the easy half. What the page says decides whether consent means anything.

Name the app, not the request. "Acme Analytics wants access to your account" tells someone who is asking. "Authorize application" tells them nothing.

Translate scopes into consequences. profile is a protocol token, not a sentence. "See your name and picture" is what the user is actually agreeing to.

ScopeWhat to write
openidConfirm who you are
emailSee your email address
profileSee your name and picture
phoneSee your phone number

Show the logo and the URL you were given, from client.logoUri and client.uri — they help a user recognise an app they know, and notice one they do not.

Make "Deny" a real button. A consent screen with one obvious action is not consent, it is a speed bump. Denying is a legitimate answer and should look like one.

Say who is signed in. If someone is logged into two accounts, the only thing that tells them which one they are about to connect is your page.

Do not render anything from the query string

authorization_id is the only parameter you should trust, and only as a lookup key. Every name, logo and scope you display must come from getAuthorization — that response came from your project. Anything in the URL came from whoever sent the user, which is precisely the party being vouched for.

On this page