Docs
Skip to content

Custom scopes

Protect the API route_

Serve tasks only to tokens that carry tasks.read.

1 min read

Raw

With the guard in place, TaskFlow can expose its task API. This is where the scope stops being a label and becomes a rule.

The task data

Create provider/src/lib/tasks.ts with an in-memory store, keyed by user ID. It stands in for your product's database so the tutorial stays focused on the OAuth side:

TypeScript
// provider/src/lib/tasks.ts
// TaskFlow's task data, keyed by the owner's user ID. A stand-in for
// your product's database so the guide stays focused on OAuth.
export type Task = {
id: string
title: string
project: string
due: string
done: boolean
}
const seed: Task[] = [
{ id: 'tsk_01', title: 'Finalize Q3 launch checklist', project: 'Launch', due: '2026-07-17', done: false },
{ id: 'tsk_02', title: 'Review onboarding copy', project: 'Growth', due: '2026-07-15', done: true },
{ id: 'tsk_03', title: 'Ship dark mode to beta', project: 'Product', due: '2026-07-21', done: false },
{ id: 'tsk_04', title: 'Prepare investor update', project: 'Ops', due: '2026-07-24', done: false },
]
const store = new Map<string, Task[]>()
/** Every TaskFlow user gets the same starter tasks the first time
* their list is read. */
export function tasksFor(userId: string): Task[] {
let tasks = store.get(userId)
if (!tasks) {
tasks = seed.map((t) => ({ ...t }))
store.set(userId, tasks)
}
return tasks
}
export function addTaskFor(userId: string, title: string): Task {
const tasks = tasksFor(userId)
const task: Task = {
id: `tsk_${String(tasks.length + 1).padStart(2, '0')}`,
title,
project: 'Inbox',
due: '2026-07-31',
done: false,
}
tasks.push(task)
return task
}

The guarded route

Create provider/src/routes/api.tasks.ts. TanStack Start serves the GET and POST handlers at /api/tasks:

TypeScript
// provider/src/routes/api.tasks.ts
import { createFileRoute } from '@tanstack/react-router'
import {
hasScope,
verifyAccessToken,
type AccessToken,
} from '../lib/resource-server'
import { addTaskFor, tasksFor } from '../lib/tasks'
// Standard OAuth resource server errors (RFC 6750): 401 when the token
// itself is bad, 403 when it's valid but missing the required scope.
function unauthorized() {
return Response.json(
{ error: 'invalid_token' },
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' } },
)
}
function forbidden(scope: string) {
return Response.json(
{ error: 'insufficient_scope', required_scope: scope },
{
status: 403,
headers: {
'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${scope}"`,
},
},
)
}
async function authenticate(request: Request): Promise<AccessToken | null> {
try {
return await verifyAccessToken(request.headers.get('authorization'))
} catch {
return null
}
}
export const Route = createFileRoute('/api/tasks')({
server: {
handlers: {
GET: async ({ request }) => {
const token = await authenticate(request)
if (!token) return unauthorized()
if (!hasScope(token, 'tasks.read')) return forbidden('tasks.read')
// The token's subject is the TaskFlow user who approved access.
return Response.json({ tasks: tasksFor(token.sub!) })
},
POST: async ({ request }) => {
const token = await authenticate(request)
if (!token) return unauthorized()
if (!hasScope(token, 'tasks.write')) return forbidden('tasks.write')
const { title } = await request.json()
return Response.json(
{ task: addTaskFor(token.sub!, title) },
{ status: 201 },
)
},
},
},
})

Each handler applies the same two checks, in order:

  1. Authentication: is the token real? A missing, forged, or expired token gets 401 invalid_token.
  2. Authorization: was this token granted the scope this operation needs? A valid token without it gets 403 insufficient_scope.

The two error shapes follow RFC 6750, including the WWW-Authenticate header, so standard OAuth client libraries understand the refusal. The distinction matters to callers: a 401 means get a new token, a 403 means ask the user for more scopes.

Note what the POST handler implies: a client can hold a perfectly valid token and still be refused a write with it, whenever the user granted tasks.read but withheld tasks.write on the consent screen.

Continue to call the API from Vantage.

Was this page helpful?

Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.