API Authentication: Sessions, Tokens, and API Keys Without the Confusion
Every project hits the same moment: the app works in the browser, someone asks for an API, and the question comes up. Sessions or tokens? JWT or something simpler? Half the answers online assume you're building a mobile app for millions of users. Most of us are building a SaaS where the browser is the main client and a handful of integrations need programmatic access.
The three options do different jobs. Picking by habit is where the bugs start.
Session cookies for your own frontend
If the client is your own web app served from the same domain, a session cookie is the boring right answer. Laravel handles it, Laravel Sanctum supports it, and the browser manages the credential for you: secure, httpOnly, SameSite. No token storage to think about, no refresh logic, no way for JavaScript to leak the credential.
The cost is CSRF, and the fix is standard: verify the CSRF token on state-changing requests and keep SameSite set. With that in place, this is the setup we reach for first. It fails when people bolt on a second domain or a native app, because cookies across origins turn into a config nightmare. If that's your future, start with tokens instead.
Bearer tokens for mobile and cross-domain clients
A mobile app or a third-party frontend can't rely on your cookies. It holds a token and sends it in the Authorization header. Two decisions matter here more than the token format.
First, storage. On mobile there's a proper secure store (Keychain, Keystore), use it. In a browser-based client, a token in localStorage is readable by any script that gets in, which turns one XSS into full account takeover. Same-domain clients should stay on cookies for exactly this reason.
Second, lifetime. Long-lived tokens that never expire are the ones that end up on Pastebin. Short access tokens plus refresh tokens, or opaque tokens you can look up server-side, cost a little more work and save a lot of pain. JWTs are fine, but they carry a real trade-off people skip past: a JWT is valid until it expires, period. You can't revoke one without building a denylist, so the "stateless, no database check" benefit quietly disappears the first time you need to ban a user.
API keys for machine-to-machine
When the client is another server, a script, or a CI job, the simplest thing that works is an API key: a random string, stored hashed in your database, checked on every request. No expiry math, no signatures, nothing to decode.
What separates a tolerable key system from a bad one:
- Store a hash, not the raw key. If your database leaks, the keys stay usable only to whoever has them, which should be nobody.
- Show the full key exactly once, at creation. After that, the user can see a prefix only.
- Scope keys where it's cheap. Read-only versus read-write covers most real abuse cases, and it means a key someone pasted into a log file can't delete data.
- Support revocation and make it instant. The whole point of server-side keys is that you can kill one without waiting for an expiry.
Rate limits belong on keys too, not just on IPs. An abusive integration should get throttled without touching the customer's own browsing session.
The mistake that shows up in production
The failure pattern we clean up most often is one global auth mechanism stretched over every client. Sessions forced onto a mobile app, JWTs with 30-day expiries "for convenience", an API key shared between the web app and the integrations because it was faster. Each of these works in a demo and rots in production.
A decent rule of thumb: cookies for your own first-party frontend, short-lived tokens for native or cross-domain clients, hashed scoped API keys for anything a customer's server calls with. Three mechanisms, each with a clear owner, none of them clever. That is usually the whole design, and it holds up.
This is one of the areas we handle when building Laravel and Vue applications at SrcTeam. If you're untangling an API that grew in three directions at once, write to us.