Authentication asks one question: who is calling. Answer it with a server-held session and you have tied your app to one machine. Answer it by verifying a token at the boundary and the same guard works on Node, on serverless, on the edge, and in an agent call.

Why sessions break at the edge

A session store is shared mutable state: a table, a Redis, a sticky server. It assumes a long-lived process with somewhere to keep the session and a way to find it again. Serverless functions are ephemeral and edge runtimes are distributed across the planet, so that assumption is exactly the one that does not hold where modern apps want to run.

Verify at the boundary, carry identity as context

Stateless auth flips it: the client presents a token (JWT or OAuth) on every request, the boundary verifies it, and the verified principal travels with the event. There is nothing to store and nothing to look up, so the same code runs anywhere a request can arrive.

ClientBearer tokenBoundaryverify JWT / OAuthHandlerevent.get('user')Responseno session touched
The token is verified once, at the boundary; the principal rides the event inward. No session store, so the same guard runs on Node, the edge and agents.

Guards are middleware

@stone-js/auth exposes guards as route middleware. requireAuth()rejects anonymous calls with a 401; requireScopes(...) additionally demands OAuth scopes, rejecting a missing one with a 403. The verified principal is then on the event.

app/TaskController.tsts
import { EventHandler, Get, Post } from '@stone-js/router'
import { requireAuth, requireScopes } from '@stone-js/auth'

@EventHandler('/tasks')
export class TaskController {
  @Get('/', { middleware: [requireAuth()] })              // must be authenticated
  list () { return this.tasks.list() }

  @Post('/', { middleware: [requireScopes('tasks:write')] })  // must hold the scope
  create (event) { return this.tasks.add(event.get('title')) }

  @Get('/mine', { middleware: [requireAuth()] })
  mine (event) {
    const user = event.get('user')          // the verified principal
    return this.tasks.ownedBy(user.id)
  }
}

Bring your own strategy

JWT and OAuth are built in. When you need something else, an API key, a bespoke provider, implement an Authenticator: it turns a request into a principal (or rejects it), and the same requireAuth/requireScopes guards work on top unchanged.

app/ApiKeyAuthenticator.tsts
import { Authenticator, AuthenticationError } from '@stone-js/auth'

export class ApiKeyAuthenticator extends Authenticator {
  async authenticate (event) {
    const key = event.getHeader('x-api-key')
    const user = await this.keys.resolve(key)
    if (user === undefined) throw new AuthenticationError('Invalid API key')  // -> 401
    return user                                    // becomes event.get('user')
  }
}

Why it matters

  • Runs everywhere. No session store means the same guard works on Node, serverless, the edge and agents, unchanged.
  • Nothing to operate. No session table, no sticky sessions, no cache to scale.
  • One guard, every context. The same requireAuth() protects an HTTP route, a CLI command and an agent tool call.

Configure issuers and keys through the auth blueprint or the environment. The full API is in Auth, and pairs with Authorization (CASL: RBAC + ABAC) when you need to decide not just who, but what they may do.