Who Is Calling, and What May They Do
Sessions against bearer tokens, where a browser token lives, and why authorisation has to happen on the object, not the route.
Authorization: Bearer <token>Authentication: what the client carries
For a first-party web client a cookie session is the better default: the browser attaches it automatically, it can be HttpOnly so no script can read it, Secure so it never crosses plaintext, and SameSite=Lax to blunt CSRF. For third-party and machine clients, a bearer token in Authorization: Bearer … (RFC 6750).
Where a token lives in a browser is the question answered wrongly most often. localStorage is readable by every script on the origin, so one XSS is total token theft, and what is stored there outlives the tab. RFC 9700, Best Current Practice for OAuth 2.0 Security (January 2025), tightened the surrounding rules: public clients must use PKCE, the implicit grant should not be used, the resource owner password credentials grant must not be used, and access tokens must not be passed in a URI query parameter. If a browser app must hold a token, keep it in memory and let a backend-for-frontend hold the refresh token in a cookie.
Authorisation: on the object, not the route
A scope like orders:read says the caller may read orders. It does not say they may read order 9001. Checking the route and forgetting the row is Broken Object Level Authorization, first in the OWASP API Security Top 10 (2023), and it looks exactly like a working endpoint. Push the tenant predicate into the query — WHERE id = $1 AND account_id = $2 — so omitting it is impossible rather than merely unlikely. Scopes should be narrow enough that denying one changes something and coarse enough that you actually check them.
Example
// auth.js
import jwt from 'jsonwebtoken';
// 1. Authentication: who is calling.
export function authenticate(req, res, next) {
const [scheme, token] = (req.get('Authorization') ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
res.set('WWW-Authenticate', 'Bearer realm="api"');
return res.status(401).json(problem('unauthenticated', 401));
}
try {
// Pin the algorithm and the audience. Omitting `algorithms` is how a
// library gets talked into accepting an unsigned or HMAC-signed token.
const claims = jwt.verify(token, PUBLIC_KEY,
{ algorithms: ['RS256'], audience: 'api.example.com', issuer: ISSUER });
// Destructuring shorthand; the long form is claims.sub, claims.scope, ...
// account_id is a private claim, not a registered one.
const { sub, scope, account_id } = claims;
req.principal = { userId: sub, accountId: account_id, scopes: scope.split(' ') };
next();
} catch {
return res.status(401).json(problem('invalid-token', 401));
}
}
// 2. The coarse check: may this caller touch orders at all?
export const requireScope = (needed) => (req, res, next) =>
req.principal.scopes.includes(needed)
? next()
: res.status(403).json(problem('insufficient-scope', 403,
{ detail: `requires scope ${needed}` }));
// 3. The check people forget: may this caller touch THIS order?
// The tenant predicate lives in the query, so no code path can omit it.
export async function orderForPrincipal(principal, orderId) {
const { rows } = await db.query(
`SELECT * FROM orders WHERE id = $1 AND account_id = $2`,
[orderId, principal.accountId]);
return rows[0] ?? null;
}
router.get('/orders/:id', authenticate, requireScope('orders:read'), async (req, res) => {
const order = await orderForPrincipal(req.principal, req.params.id);
// 404, not 403: do not confirm that another tenant's order exists.
if (!order) return res.status(404).json(problem('order-not-found', 404));
res.json(order);
});When to use it
- Choosing between a cookie session and a bearer token for a new web client
- Auditing endpoints for the missing tenant filter that lets one customer read another's data
- Designing the scope list for a public API before partners start requesting keys
- Deciding whether a forbidden object returns 403 or 404
More examples
A cookie session for a first-party web client
Reach for this whenever the browser and the API share a site. The tradeoff is explicit: you take on CSRF in exchange for a credential that script cannot read and that you can revoke server-side, which is the better side of the trade for a first-party app.
import session from 'express-session';
app.use(session({
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false, // no cookie until there is a session
store: new RedisStore({ client: redis }), // server-side state: revocable
cookie: {
httpOnly: true, // script cannot read it, so XSS cannot exfiltrate it
secure: true, // never sent over plaintext HTTP
sameSite: 'lax', // blunts CSRF on cross-site POSTs; 'strict' breaks OAuth returns
path: '/',
maxAge: 12 * 60 * 60 * 1000,
},
}));
// SameSite is a mitigation, not the control. Keep a CSRF token for
// state-changing requests from forms.
app.post('/orders', csrfProtection, createOrder);
// Why this beats a token in localStorage for a first-party app:
// - HttpOnly means XSS cannot read the credential
// - server-side store means logout and revocation are real, immediately
// - the browser attaches it, so no client code touches the credential at all
// What it costs: CSRF protection, and it does not work for a third-party client.Scopes that are worth checking
The test for a scope being worth having is whether anyone would ever issue a key without it — `refunds:write` passes, a hypothetical `orders:read:total` does not. Splitting refunds from order writes is the one that earns its keep: it lets a reporting or fulfilment integration hold a key that cannot move money.
// Too coarse: every integration gets everything, so the check proves nothing.
// scope: "api"
// Too fine: nobody can reason about it and every new endpoint needs a
// migration of every API key.
// scope: "orders.shipping_address.read orders.total_cents.read ..."
// About right: resource plus action, with read and write split, because
// read-only integrations are the common case and the valuable restriction.
const SCOPES = [
'orders:read', 'orders:write',
'customers:read', 'customers:write',
'refunds:write', // deliberately separate from orders:write
'reports:read',
];
// Writes need the write scope AND the object check; the scope alone is
// a statement about the caller, not about the row.
router.post('/orders/:id/refunds',
authenticate,
requireScope('refunds:write'),
async (req, res) => {
const order = await orderForPrincipal(req.principal, req.params.id);
if (!order) return res.status(404).json(problem('order-not-found', 404));
res.status(201).json(await refunds.create(order, req.body));
});Property-level authorisation, or mass assignment
This is OWASP's API3:2023, Broken Object Property Level Authorization, and the broken version appears wherever an ORM makes `update(req.body)` a one-liner. An allowlist per role is the only form that stays correct as the schema grows; a denylist silently starts accepting every column you add.
// BROKEN: the caller decides which columns change.
// POST /users/42 {"name":"Ada","role":"admin","account_id":9}
router.patch('/users/:id', authenticate, async (req, res) => {
await db.query('UPDATE users SET ? WHERE id = ?', [req.body, req.params.id]);
});
// FIXED: an allowlist per role. Fields absent from the list are not an error
// to argue about -- they simply do not exist as far as this handler is concerned.
const WRITABLE = {
member: ['name', 'locale', 'timezone'],
admin: ['name', 'locale', 'timezone', 'role'], // role, but still never account_id
};
router.patch('/users/:id', authenticate, requireScope('users:write'), async (req, res) => {
const target = await db.oneOrNone(
'SELECT id FROM users WHERE id = $1 AND account_id = $2', // object check first
[req.params.id, req.principal.accountId]);
if (!target) return res.status(404).json(problem('user-not-found', 404));
const allowed = WRITABLE[req.principal.role] ?? [];
const patch = Object.fromEntries(
Object.entries(req.body).filter(([k]) => allowed.includes(k)));
if (Object.keys(patch).length === 0)
return res.status(422).json(problem('no-writable-fields', 422));
res.json(await users.update(target.id, patch));
});
Discussion