
On this page
A few weeks ago I picked up a ticket that said, more or less, "implement the auth layer." The application already had authentication working, so the first job wasn’t writing code, it was reading what was already there.
What I found: on login the API issued an access token, the frontend saved it in localStorage, and every request went out with Authorization: Bearer . If you’ve built a SPA in the last ten years, this is probably the default picture in your head too. It isn’t wrong. It just didn’t look like the best fit for this application, and the AdonisJS guard table points somewhere else for a case like ours, once you actually read it instead of skipping past it.
If you want the browser-side mechanics of how Web Storage and Set-Cookie actually differ, Alencar already covered that ground in LocalStorage and Cookies under the hoodies. This post starts one layer up: choosing between session cookies and bearer tokens in AdonisJS, and what each choice costs you afterwards.
Before we start. This is not a tutorial on setting up an auth layer "the right way", and I’m not a security expert. It’s a write-up of a decision I had to make on one real application, the trade-offs I weighed, and what I’d want to check before making the same call again. Read it as a starting point for your own research, not as a security checklist. If you’re building something where getting this wrong really hurts, talk to someone who does security for a living.
What was already there
AdonisJS ships three guards out of the box:
The app was on the tokens guard, configured the usual way:
// config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
export default defineConfig({
default: 'api',
guards: {
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
},
})And the login endpoint:
// app/controllers/session_controller.ts
async store({ request, auth }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
return await auth.use('api').createToken(user)
}Before I say anything critical, credit where it’s due: this is a solid token implementation, and the tokens guard does more than people give it credit for (User.verifyCredentials alone is worth a read of its own). The tokens are opaque, not JWTs. They’re random strings, stored in your database as hashes, verified by comparing hashes. That means you can revoke one instantly by deleting a row, which is exactly the thing you cannot do with a JWT until it expires on its own. Each token carries abilities (projects:read, admin, *), an optional expiresIn, and a last_used_at column the guard updates on every authenticated request. The public value even gets an oat_ prefix and a checksum so secret scanners can spot it if it leaks into a repo.
None of that was what bothered me. What bothered me was the last mile: where the browser keeps it.
Where tokens actually belong
Let me be fair to the approach before I move away from it. Access tokens are the right call when the client can’t do cookies, or shouldn’t:
- Native mobile and desktop apps. There’s no cookie jar you want to reason about here.
- CLI tools and server-to-server integrations. A long-lived credential the machine stores is the entire point.
- Third-party API access. You want per-token abilities and a revoke button in a settings screen.
- A frontend on a genuinely different site than your API. More on this below, it’s the whole ballgame.
If any of those describe your client, you can probably stop reading and keep your tokens. The rest of this post is about the case where they don’t.
Where it hurts in a browser
Our client was a SPA, served from the same site as the API, talking to nothing else. And in that setting localStorage has a specific, well-known problem: anything running JavaScript on your page can read it. Not a hypothetical attacker with database access. A compromised npm dependency. A third-party analytics script. One reflected XSS in a form you forgot to escape.
And what they get is worse than a hijacked session. They get a bearer token, a portable credential that works from anywhere. Copy it out, use it from a laptop in another country, keep using it until it expires. Nothing in the request says "this must come from the victim’s browser."
Meanwhile, the plumbing costs you something every day:
- You maintain the header on every request, in every client, forever.
- The credential’s lifetime is managed by hand refresh logic, expiry handling, "why am I logged out on this tab but not that one."
- Logout is
localStorage.removeItem()unless you also remember to invalidate server-side.
The docs put the responsibility plainly: the client application is responsible for storing tokens securely. In a browser, "securely" is doing a lot of work in that sentence.
Cross-origin is not cross-site
Before going further, there’s a distinction that decides which guard is even available to you, and it’s easy to blur:
app.example.com→api.example.comis cross-origin but same-site. You need CORS. YourSameSite=Laxcookie still flows. Sessions are a realistic option here.my-app.vercel.app→api.example.comis cross-site. Now your session cookie is a third-party cookie. You needSameSite=None; Secure, and from there you’re subject to whatever each browser currently thinks about third-party cookies. That’s not a configuration problem you can solve it’s a policy you’re a guest of.
This is why the AdonisJS guard table recommends sessions for server-rendered apps and SPAs on the same top-level domain, and access tokens for a SPA on a different domain. Read that way, it looks less like a taste preference and more like a browser constraint dressed up as a recommendation.
The move: the session guard
The switch itself is almost boring, which is the nice part. The session guard guide covers it end-to-end; here’s the short version.
node ace add @adonisjs/auth --guard=session// config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
export default defineConfig({
default: 'web',
guards: {
web: sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})// app/controllers/session_controller.ts
async store({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
return response.noContent()
}
async destroy({ auth, response }: HttpContext) {
await auth.use('web').logout()
return response.noContent()
}Routes are protected the same way they were before, with middleware.auth(). The controller barely changed. What changed is the shape of the credential: the browser now holds a session ID in a cookie, and the identity itself lives in your session store, on your side of the wire.
One thing you get for free here and should know about anyway: session fixation. When a user logs in, their session ID has to be regenerated; otherwise, an ID an attacker planted before login stays valid after it. The auth package does this for you. If you ever hand-roll login, you have to call session.regenerate() yourself.
The cookie settings that mattered
This is the file I kept coming back to, and it’s easy to skim past because the defaults are already sensible:
// config/session.ts
export default defineConfig({
age: '2h',
cookie: {
path: '/',
httpOnly: true, // document.cookie can't touch it
secure: app.inProduction, // HTTPS only where it counts
sameSite: 'lax', // the CSRF baseline
},
store: env.get('SESSION_DRIVER'),
// ...stores
})Four lines, three real decisions:
httpOnly: trueis the whole reason we’re here. The session ID is invisible to JavaScript, so an XSS can’t exfiltrate it.securedefaults toapp.inProductionso local HTTP development still works. I left it exactly as it came.sameSitedecides when the browser is willing to attach the cookie to a cross-site request.laxis what ships and what we kept;strictis tighter but may break a flow you care about;nonerequiressecure: trueand means "attach it always."
Then pick your store deliberately. The cookie driver keeps session data in an encrypted cookie and silently truncates anything over ~4KB — fine while your session is small, easy to get bitten by once it isn’t. file is fine on a single box. Once you have more than one server, it’s redis or database, and that’s a real infrastructure line item you didn’t have with tokens.
Two CORS defaults worth knowing
Cookies only reach your API if the browser is convinced it’s allowed to send them, and by default it is not. The general mechanics are covered better by MDN and the CORS guide than by me. Two things are specific to Adonis and worth having in your head before you stare at a 401 with no cookie attached:
- The default is
origin: app.inDev ? true : ['https://app.example.com']. Wide open locally, closed shut in production. That’s a good default, and also exactly the thing that will "break" your first deploy. It isn’t broken it’s waiting for you to name your frontend. origin: '*'andcredentials: trueare incompatible. Browsers reject the combination outright, so Adonis reflects the requesting origin back instead of emitting a literal*. Useful to know; not a strategy. List your origins.// config/cors.ts export default defineConfig({ enabled: true, origin: app.inDev ? true : ['https://app.example.com'] credentials: true, // sends Access-Control-Allow-Credentials maxAge: 90, })
The rest is a two-sided handshake: credentials: true on the server, credentials: 'include' on the client. Miss either side and the cookie quietly doesn’t travel.
Our frontend is React with Vite and TanStack, talking to Adonis through Tuyau, so the client half is one option at the point where the client is created:
export const tuyau = createTuyau({
baseUrl: env.VITE_API_URL,
registry,
credentials: 'include',
})Worth knowing if you’re on the same stack: with credentials: 'include' set, Tuyau deals with CSRF for you the read-the-cookie, echo-it-in-a-header round-trip you’d otherwise hand-write in a fetch wrapper.
In development, our baseUrl points straight at the Adonis port, and the dev default above lets that through without any of this mattering. Which is another way of saying development never really tests this part.
What I traded away
Moving the credential out of JavaScript’s reach doesn’t delete risk, it relocates it. Being honest about the new one:
Bearer tokens are immune to CSRF. The browser never attaches an Authorization header on its own — your code does. A cookie is the opposite: the browser attaches it automatically to requests aimed at your domain, including ones initiated by a page you didn’t write. So the moment you move to cookies, CSRF becomes your problem.
The answers are known and cheap:
sameSite: 'lax'blocks the classic cross-site form POST and covers most of it.@adonisjs/shieldadds real CSRF protection, withenableXsrfCookiefor the SPA flow where the client reads a token and echoes it in a header.- Scope your
exceptRoutesnarrowly. Every exception is a hole you drilled on purpose.
And what I got in exchange, beyond the XSS story: logout became real. Destroying the session invalidates it server-side, everywhere, immediately. No "delete the key and hope."
Side by side
Token in localStorage | Session + httpOnly cookie | |
|---|---|---|
| Where the credential lives | Client JS storage | Server store; browser holds only an ID |
| Readable by page JavaScript | Yes | No |
| Worst case under XSS | Token stolen, replayed off-device | Attacker acts within the victim’s browser only |
| CSRF exposure | None by default | Real — needs SameSite + shield |
| Frontend on a different site | Works | Third-party cookie territory |
| Mobile / CLI / third-party clients | Works | Doesn’t |
| Revocation | Delete the token row | Destroy the session |
| Extra infrastructure | auth_access_tokens table | Session store (Redis in production) |
| Per-credential permissions | Abilities, expiry, names | Not built in |
Shipping it without logging everyone out
Here’s the part the config doesn’t tell you about. The application already had users walking around with tokens in their browsers. Flipping the default guard invalidates every one of them at the same instant.
AdonisJS makes the way out almost underwhelming: register both guards, and let a route accept either one.
router
.get('projects', [ProjectsController])
.use(middleware.auth({ guards: ['web', 'api'] }))Authentication succeeds if either guard succeeds. So for a while both things were true at once. The frontend started issuing sessions the moment it deployed, and anyone still carrying a token kept working exactly as before, on the same routes, with no branch in the code to maintain.
Nobody got pushed. Users moved onto sessions passively, at their next login. We gave that a week based on our records and then dropped the auth_access_tokens table.
As migrations go, it was uneventful. Somebody was surely logged out somewhere in that week; it was a cost we’d accepted going in, and no one reported it.
And that mechanism outlives the migration. Registering two guards isn’t only a cutover trick it’s what lets one codebase serve a browser client with cookies and a machine client with tokens, on the same routes, with no fork in your authorization logic. We needed it for seven days. The seam stays there for free.
My thoughts after doing the migration
Neither of these is "the secure one." They fail differently, and you pick based on who is holding the credential.
For us, a browser client, our own frontend, no other consumers; the session guard was the better fit, and Adonis made it a small diff: one guard swap, httpOnly cookies, CORS with credentials, the CSRF question answered honestly. For a phone, a CLI, a partner’s server, or a frontend parked on someone else’s domain, I’d reach for tokens and not feel like I’d settled for anything. The opaque, revocable, ability-scoped implementation Adonis ships is a good one.
What took me longest to internalize is that the framework never asked me to choose once. I spent a while looking for the right answer to "sessions or tokens," and the useful question turned out to be "right for which client?" which the guard system was already built to let me answer in more than one way.
The docs I leaned on
- Authentication: introduction — the guard table, and the one page I’d read first
- Session guard — login, logout, protecting routes, remember me
- Access tokens guard — abilities, expiration, revoking
- Sessions — cookie options and picking a store
- CORS — origins, credentials, preflight
- Securing SSR apps — the shield package and CSRF
We want to work with you. Check out our Services page!


