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.
Scheme names
Section titled “Scheme names”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.
Sanctum
Section titled “Sanctum”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 fromauth:sanctum, the baresanctumalias, anabilities:/ability:middleware, or anyauth:<guard>whose configured driver issanctum— so a customauth:mobileguard on the Sanctum driver is recognized, and multi-guard lists (auth:web,sanctum) work. - Stateful SPA → a cookie-based scheme (
sanctumStateful), documented whenEnsureFrontendRequestsAreStatefulis present alongside an auth guard. BecausestatefulApi()prepends that middleware to the wholeapigroup, 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 theX-XSRF-TOKENflow.
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:
Route::get('/invoices', [InvoiceController::class, 'index']) ->middleware('auth:sanctum');"components": { "securitySchemes": { "sanctumToken": { "type": "http", "scheme": "bearer", "description": "Sanctum API token authentication: send a personal access / API token as a `Bearer` token in the `Authorization` header." } }}// On the operation. With statefulApi() in play the route supports both modes, so this becomes// [ { "sanctumToken": [] }, { "sanctumStateful": [] } ] — either one satisfies it."security": [ { "sanctumToken": [] } ]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.
Token abilities
Section titled “Token abilities”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"] } ]} }Passport
Section titled “Passport”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)Declaring security yourself
Section titled “Declaring security yourself”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_middlewareon its own does nothing visible — it decides which routes are “protected”, anddefaultdecides what requirement they carry. Set both. The pattern is a wildcard matched against each route’s middleware strings, so the shippedauth*coversauth,auth:web,auth:sanctumand 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’sAuthenticate— the oneapp/Http/Middleware/Authenticate.phpevery app upgraded from Laravel 10 still has — is read asauth, 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
schemesstands 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.
Declaring security in code
Section titled “Declaring security in code”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 (fromsecurity.schemesconfig 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, emittingsecurity: [{}, …]— 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 { /* … */ }"/api/reports": { "get": { "security": [ { "oauth2": ["reports.read"] }, { "apiKey": [] }] } }"/api/feed": { "get": { "security": [ {}, { "sanctumToken": [] } ] } }Authorization: roles & permissions
Section titled “Authorization: roles & permissions”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.
Configuration
Section titled “Configuration”| 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.
Pin the values your clients act on
Section titled “Pin the values your clients act on”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.cookiePin 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.
Opting out of middleware
Section titled “Opting out of middleware”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.
Implicit 401
Section titled “Implicit 401”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.