Skip to content

Authentication

Docuccino detects how your routes are protected from their middleware and documents the appropriate security schemes and requirements. There are three ways an operation ends up with security, and they compose in a fixed order:

Source Layer When it applies
A package integration (Sanctum, Passport) integration Its package is installed, the route’s middleware matches, and you declared no security.schemes of your own.
Your security config integration / config You declared schemes — which also stands the integrations down — plus a default requirement for auth-detected routes, or a document-wide one.
An attribute (#[Security], #[OptionallyAuthenticated], #[Unauthenticated]) attribute Always wins over both, per operation.

Middleware a route inherits counts the same as middleware written on it: a group’s members are read as if each route in the group carried them, so an api group holding auth:sanctum protects every route in it as far as the document is concerned. Groups nested inside groups are expanded too.

Routes marked #[Unauthenticated] are documented as public (security: []) whatever the middleware says.

Whichever route documents them, schemes are registered in components.securitySchemes under stable names — the names an operation’s security requirement and your own #[Security] attributes reference:

Name Type Registered by
sanctumToken http / bearer Sanctum, on a route with a token guard
sanctumStateful apiKey in cookie Sanctum, on a route with the stateful-frontend middleware and an auth guard
passport oauth2 Passport, on a Passport-protected route
whatever you called it anything Your security.schemes config

A scheme keeps its plain name while one definition holds it. If a build produces two different definitions under one name — an app that never called Passport::tokensCan() builds a passport scheme per distinct scope set — the plain name is retired and each takes a name derived from its own definition (passport_kzvq2m4a), with a components.name-collision warning naming both. That keeps the name a security requirement points at a function of the definitions rather than of which route sorted first; calling Passport::tokensCan() with your real scope catalog gives every route one definition and one plain passport.

Activates when Laravel Sanctum is installed. Docuccino documents both of Sanctum’s modes based on the middleware a route uses:

  • Token → an HTTP bearer scheme (sanctumToken). Detected from auth:sanctum, the bare sanctum alias, an abilities: / ability: middleware, or any auth:<guard> whose configured driver is sanctum — so a custom auth:mobile guard on the Sanctum driver is recognized, and multi-guard lists (auth:web,sanctum) work.
  • Stateful SPA → a cookie-based scheme (sanctumStateful), documented when EnsureFrontendRequestsAreStateful is present alongside an auth guard. Because statefulApi() prepends that middleware to the whole api group, requiring the guard too means public routes (login, register) are left unsecured rather than falsely cookie-protected. OpenAPI can’t model the CSRF handshake structurally, so the scheme’s description explains the X-XSRF-TOKEN flow.

A route that supports both — the common Sanctum reality — gets an OR-list: either credential satisfies it. The middleware alone is enough; you write no security config:

routes/api.php
Route::get('/invoices', [InvoiceController::class, 'index'])
->middleware('auth:sanctum');

Tune which schemes are exposed, in docuccino.yaml:

documents:
default:
integrations:
sanctum:
modes: ['token', 'stateful'] # which schemes to expose (default: both)
cookie: 'myapp_session' # stateful cookie name (default: your session.cookie)

modes is intersected with what each route actually supports, so it narrows rather than invents — which makes it a clean audience switch across multiple documents: a public document can list only token while an internal one lists both.

Sanctum’s abilities: (all-of) and ability: (any-of) middleware — and the deprecated CheckScopes / CheckForAnyScope class forms — describe which token abilities a route needs. Since the sanctumToken scheme is an HTTP bearer token, OpenAPI can’t carry abilities as scopes, so Docuccino surfaces them as an x-abilities extension member and appends a requirement line to the operation’s description (“Requires token abilities: invoices:create, invoices:send”). Abilities checked in the action body ($request->user()->tokenCan(…)) can be declared with #[Abilities], which is an all-of requirement:

#[Abilities('invoices:create', 'invoices:send')]
public function store(StoreInvoiceRequest $request): InvoiceResource { /* … */ }
"/api/invoices": { "post": {
"x-abilities": [ { "match": "all", "abilities": ["invoices:create", "invoices:send"] } ]
} }

Activates when Laravel Passport is installed. A route is documented with the passport OAuth2 scheme when it carries scope: / scopes: or client-credentials middleware, or when one of its auth:<guard> middleware resolves to a passport-driver guard. Detection is driver-based, from config('auth.guards') — so a custom-named guard (auth:partner on the passport driver) is recognized, a guard on a token/sanctum driver is not mistaken for Passport, and multi-guard lists (auth:web,api) work.

The authorization-code and client-credentials flows are always emitted; the password and implicit flows appear only when the app opted into them (Passport::enablePasswordGrant() / enableImplicitGrant()) — so the docs never advertise a grant the server rejects. The flow endpoints honor config('passport.path'), and the flow scope map is your real scope catalog from Passport::tokensCan(). Any scope a route references that the catalog is missing is added (described by its own id) so the document stays valid; an app that never called tokensCan() gets a single * scope.

Per-operation scopes are read from the middleware and modeled by their Laravel semantics: scopes: (CheckScopes) requires all listed scopes (one requirement), while scope: (CheckForAnyScope) requires any one of them, emitted as an OR-list of requirements. The same applies to machine-to-machine routes: client / CheckClientCredentials (all-of) and CheckClientCredentialsForAnyScope (any-of) are documented against the client-credentials flow, and a bare client with no scope is still marked protected.

documents:
default:
integrations:
passport:
url: 'https://auth.example.com' # OAuth2 flow base URL (default: your app URL)

Some apps don’t use Sanctum or Passport — a hand-rolled JWT guard, an API key header, an external identity provider. Declare the schemes in docuccino.yaml and Docuccino publishes them verbatim, in full OpenAPI breadth (http bearer/basic, apiKey in header/query/cookie, oauth2 with any flows, openIdConnect):

documents:
default:
security:
# Routes whose middleware matches this wildcard get the `default` requirement below.
auth_middleware: 'auth*'
schemes:
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }
apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' }
# The per-operation requirement applied to routes matched by auth_middleware.
default: [{ bearer: [] }]

Two things worth knowing:

  • auth_middleware on its own does nothing visible — it decides which routes are “protected”, and default decides what requirement they carry. Set both. The pattern is a wildcard matched against each route’s middleware strings, so the shipped auth* covers auth, auth:web, auth:sanctum and friends.
  • A route can name the same middleware two ways — the alias, or the middleware’s own class name, which is what Authenticate::using('web') writes — and the pattern is matched against both, so your own narrower pattern keeps its meaning whichever spelling a route uses. Your app’s own authenticator counts as well: a middleware extending Laravel’s Authenticate — the one app/Http/Middleware/Authenticate.php every app upgraded from Laravel 10 still has — is read as auth, whether or not you registered an alias for it. In the pattern, * stands for any run of characters and everything else is literal, so a pattern naming a class can be written with single backslashes.
  • Declaring any schemes stands the Sanctum and Passport integrations down entirely — explicit config wins, so you never get a surprise second scheme alongside your own.

Use security.document instead of default for a requirement that applies to the whole document; see the configuration reference for the full key list.

Middleware detection covers the common cases, but some requirements live where static analysis can’t see them — a Gate or policy, or a $request->user()?->tokenCan(…) check in the action body. Declare those with attributes; they apply at the attribute precedence layer, so they win over anything inferred from middleware.

  • #[Security(scheme, scopes)] names a requirement against a registered scheme (from security.schemes config or an integration). Several scopes in one attribute are an all-of; the attribute is repeatable, so stacking it models an OR-list where any one alternative satisfies the operation.
  • #[OptionallyAuthenticated] marks an endpoint usable anonymously or authenticated, emitting security: [{}, …] — the empty requirement followed by whatever was declared or inferred.
// Either an OAuth2 token with reports.read, or an API key:
#[Security('oauth2', ['reports.read'])]
#[Security('apiKey')]
public function reports(): JsonResponse { /* … */ }
// Route is behind `auth:sanctum`, so `sanctumToken` is inferred; #[OptionallyAuthenticated]
// adds the anonymous alternative — works signed-out, richer response with a token:
#[OptionallyAuthenticated]
public function feed(): JsonResponse { /* … */ }

Authenticating a request is one thing; authorizing it — the role: / permission: middleware from spatie/laravel-permission — is documented by a separate, opt-in integration, because permission names expose your internal authorization taxonomy. See Spatie Laravel Permission.

Integration Option Default Effect
any enabled true Turn the integration on/off for this document.
sanctum modes both Which Sanctum schemes to expose, intersected with each route’s real support.
sanctum cookie your session.cookie Stateful cookie name.
passport url your app.url OAuth2 flow base URL.

Each integration is a no-op when its package isn’t installed, or when a route has no matching middleware. Sanctum and Passport stay on by default — token schemes and OAuth scopes are the public contract, so they leak nothing internal.

Those two defaults read your application’s own config, and both of those keys come from the environment: Laravel ships 'url' => env('APP_URL', 'http://localhost') and 'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel'), '_').'_session'). So the same code documented on a laptop and in CI publishes different bytes — and an app that never set APP_URL publishes http://localhost/oauth/token as the endpoint every client should get its tokens from.

Docuccino always publishes what it finds: OpenAPI requires a tokenUrl on every flow object, and a cookie scheme with the wrong name makes clients send the wrong cookie, so withholding either would be worse than a value that needs checking. Instead the build warns with a config.machine-dependent-value diagnostic naming the value, the config key it came from, and the option that pins it:

documents:
default:
integrations:
passport: { url: 'https://auth.example.com' } # instead of your app.url
sanctum: { cookie: 'example_session' } # instead of your session.cookie

Pin them and the warning goes away, because the document no longer depends on where it was built.

Neither warning fires on an application that chose the value itself. A Passport base URL a client can actually reach — anything but the loopback range in any of its spellings (localhost, 127.0.0.1, 127.1, 2130706433, 0.0.0.0, ::1, ::ffff:127.0.0.1) or a name under .localhost / .test / .local / .example / .localdomain — is taken at face value, and reported on only if the key it came from was empty. A Sanctum cookie name is reported only when it is exactly what Str::slug(config('app.name'), '_').'_session' produces, which is Laravel’s shipped environment-derived default; a config/session.php that names the cookie itself has pinned it, and is left alone. The cookie warning is also raised once for the document, not once per route: one cookie name reaches every stateful operation, so a stateful app would otherwise be told the same thing several hundred times.

A route bound to a host — Route::domain(config('app.admin_domain')) — publishes that host in the operation’s servers, and gets the same rule for the same reason: an admin domain read out of the environment sends every client to a URL only the build machine can reach.

Run docuccino:export --fail-on=warning in CI to stop a document built against the wrong environment from ever being released.

withoutMiddleware() is honored, and subtracted the way Laravel subtracts it — through the alias map, so opting out of auth:web with Authenticate::using('web') really does remove it and the route is documented without its 401.

Your own aliases and your own middleware groups are read too: they are registered on the router when the HTTP kernel is constructed, and a build resolves that kernel so it reads the same map a request does. Where an exclusion still resolves to nothing and removed nothing — a typo, usually — the build says so with route.unmatched-exclusion rather than leaving you to find it. Writing both sides of an opt-out in the same spelling — the class name on both, or the alias on both — reads best either way.

When a route sits behind authentication middleware (matching security.auth_middleware), Docuccino also documents the 401 response an unauthenticated request receives — no annotation needed. Mark a route #[Unauthenticated] to opt out. See implicit responses.