Authentication checks who is calling, authorization decides what they may do. How the two fit together in a backend API, the common models for each, and TypeScript code for both.
Authentication and authorization are the two checks every API runs on a protected request, and they are easy to blur because they usually live in the same middleware. Authentication answers who is calling, by verifying a password, a session or a token, and authorization answers whether that caller may read this invoice or delete that project.
Mixing them up shows up as concrete bugs: endpoints that check a token is valid but never check it belongs to the account being edited, or 403 responses where the client should have been sent to log in. This guide separates the two, shows where each runs in a backend, and implements both in TypeScript.
Each option is covered below, starting with Encore.
Encore is an open source backend toolkit for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Authentication is part of the API layer: you write one auth handler that reads the credentials it cares about, such as the Authorization header or a session cookie, and returns typed auth data, and every endpoint marked auth: true is rejected with an unauthenticated error before your code runs if that handler did not succeed.
// auth/auth.ts
import { APIError, Gateway, Header } from "encore.dev/api";
import { authHandler } from "encore.dev/auth";
import { verifyToken } from "./tokens"; // your JWT or session lookup
interface AuthParams {
authorization: Header<"Authorization">;
}
export interface AuthData {
userID: string;
role: "member" | "admin";
}
export const auth = authHandler<AuthParams, AuthData>(async (params) => {
const token = params.authorization.replace(/^Bearer /, "");
const session = await verifyToken(token);
if (!session) {
throw APIError.unauthenticated("invalid token");
}
return { userID: session.userId, role: session.role };
});
export const gateway = new Gateway({ authHandler: auth });
Run in your terminal to get started locally.
Authorization then lives next to the business rules, inside the endpoint, where getAuthData() returns the typed result of the handler, and the endpoint decides whether this user may act on this resource:
// projects/projects.ts
import { api, APIError } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
import { db } from "./db";
export const remove = api(
{ expose: true, auth: true, method: "DELETE", path: "/projects/:id" },
async ({ id }: { id: string }): Promise<void> => {
const user = getAuthData()!;
const project = await db.queryRow<{ owner_id: string }>`
SELECT owner_id FROM projects WHERE id = ${id}`;
if (!project) {
throw APIError.notFound("project not found");
}
// Authorization: admins can delete anything, members only their own projects.
if (user.role !== "admin" && project.owner_id !== user.userID) {
throw APIError.permissionDenied("not allowed to delete this project");
}
await db.exec`DELETE FROM projects WHERE id = ${id}`;
},
);
Checks that apply to many endpoints, such as "admins only", fit in a service middleware that targets auth: true endpoints, and the Encore examples repository includes one for user authorization. When you run encore run, the local development dashboard shows a trace for every request, which makes it quick to see whether a request was rejected as unauthenticated or by a permission check. That split also helps an AI coding agent working in the repository, since the auth handler and getAuthData() are ordinary typed code it can read, extend with a new role, and test locally in the same change.
Authentication turns credentials into a verified identity, and the most common forms in web backends are server-side sessions stored in a cookie, signed tokens such as JWTs carried in the Authorization header, API keys for machine clients, and federated login through OpenID Connect, where an identity provider signs an ID token that your backend validates. Passwords, passkeys and one-time codes are how users prove themselves to whichever system issues those sessions or tokens.
A common setup hands the login flow to a provider such as Clerk, Auth0, WorkOS or Better Auth, and keep only token validation in the API. Whatever issues the credentials, the backend still validates them on every request: check the signature and expiry of a JWT, look up the session for a cookie, and reject anything that does not verify. The OWASP Authentication Cheat Sheet covers the details, such as generic error messages and rate limits on login.
Authorization takes the verified identity and decides whether a specific action on a specific resource is allowed. Role-based access control (RBAC) assigns users roles such as member and admin and grants permissions per role, which covers most SaaS apps. Attribute-based access control (ABAC) evaluates attributes of the user, the resource and the request, such as "editors in the same organization during business hours", and relationship-based models in the style of Google Zanzibar decide based on graphs like "user is a member of the team that owns the document".
The failure that matters most here is broken object level authorization, which sits at the top of the OWASP API Security Top 10: an endpoint checks that the caller is logged in but not that the object ID in the path belongs to them. The ownership check in the remove endpoint above is exactly the line that prevents it, and it has to exist in every endpoint that takes an object ID.
OAuth 2.0 is an authorization framework for delegated access, defined in RFC 6749, and on its own it does not tell your app who the user is; OpenID Connect adds that identity layer on top. HTTP naming adds to the confusion, since the 401 status is called "Unauthorized" while it really signals missing or invalid authentication, and 403 Forbidden is the authorization failure. Return 404 instead of 403 when even revealing that a resource exists would leak information.
Keep authentication in one place at the edge of the API, so every request is verified the same way and endpoints receive a typed identity rather than raw tokens. Keep authorization next to the data it protects, in the endpoint or a shared helper, because the rule usually depends on the resource being loaded.
Pick Encore when you want that split built into a TypeScript or Go backend, with one auth handler in front of every auth: true endpoint, typed auth data in your handlers, and traces that show each check while you develop locally and after you deploy to your own AWS or GCP account.
Authentication verifies who a caller is, for example by checking a password, a session cookie or a signed token. Authorization runs after it and decides whether that verified caller is allowed to perform a specific action on a specific resource.
Return 401 Unauthorized when the request has no valid credentials, which is an authentication failure. Return 403 Forbidden when the caller is known but lacks permission for the action, which is an authorization failure.
Define an authHandler from encore.dev/auth that validates the credentials and returns AuthData such as a user ID and role, and register it on a Gateway. Mark endpoints with auth: true, then read getAuthData() from ~encore/auth inside the handler and throw APIError.permissionDenied when the caller lacks the required role.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.