Every API needs authentication eventually. Whether you're protecting user data, rate limiting by account, or implementing multi-tenancy, you need to know who's making requests. This guide covers the main authentication patterns for TypeScript backends with practical, copy-paste examples.
Quick clarification before we start:
This guide focuses on authentication. Once you know who the user is, authorization is straightforward, and we'll cover it briefly at the end.
We'll use Encore.ts which has built-in auth handler support. The auth handler pattern centralizes authentication logic so you don't repeat it in every endpoint.
# Install CLI
brew install encoredev/tap/encore
# Create project
encore app create auth-demo --example=ts/hello-world
cd auth-demo
# Start dev server
encore run
Encore uses an auth handler that runs before any protected endpoint. It extracts credentials from the request, validates them, and returns user data that your endpoints can access. This keeps authentication logic in one place.
Create the auth service directory and declaration:
mkdir auth
Create auth/encore.service.ts:
import { Service } from "encore.dev/service";
export default new Service("auth");
Create auth/auth.ts. The auth handler receives request parameters (like headers) and returns data about the authenticated user. If authentication fails, throw an error.
import { authHandler, Header } from "encore.dev/auth";
import { APIError } from "encore.dev/api";
// Data available to all authenticated endpoints
export interface AuthData {
userId: string;
email: string;
role: "user" | "admin";
}
// Parameters extracted from the request
interface AuthParams {
authorization: Header<"Authorization">;
}
export const auth = authHandler<AuthParams, AuthData>(
async (params) => {
// Extract token from header
const token = params.authorization?.replace("Bearer ", "");
if (!token) {
throw APIError.unauthenticated("missing authorization header");
}
// Validate token and return user data
const user = await validateToken(token);
return user;
}
);
async function validateToken(token: string): Promise<AuthData> {
// Implementation depends on your auth strategy
// See examples below
throw new Error("Not implemented");
}
Now protect any endpoint with auth: true. The auth handler runs automatically before your handler, and you can access the authenticated user's data.
import { api } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
export const getProfile = api(
{ expose: true, auth: true, method: "GET", path: "/profile" },
async (): Promise<{ userId: string; email: string }> => {
// getAuthData() returns the data from your auth handler
const auth = getAuthData()!;
return {
userId: auth.userId,
email: auth.email,
};
}
);
JSON Web Tokens are stateless tokens that contain encoded user data. The server signs them with a secret; clients send them with requests. JWTs are popular for APIs because they don't require server-side session storage.
Install the JWT library. We'll use jose which is a modern, well-maintained library that works in all JavaScript runtimes.
npm install jose
Create a secret for signing tokens. In production, use Encore secrets which encrypts the value and injects it at runtime, never exposing it in code or environment variables.
// auth/secrets.ts
import { secret } from "encore.dev/config";
export const jwtSecret = secret("JWTSecret");
Set the secret locally using the Encore CLI. For production, set it in the Encore Cloud dashboard.
encore secret set --type local JWTSecret
# Enter a strong random string (32+ characters)
Create auth/jwt.ts with functions to create and verify tokens. The jose library handles the cryptographic details while we define the token structure and expiration.
import { SignJWT, jwtVerify, JWTPayload } from "jose";
import { jwtSecret } from "./secrets";
interface TokenPayload extends JWTPayload {
userId: string;
email: string;
role: "user" | "admin";
}
export async function generateToken(payload: Omit<TokenPayload, "iat" | "exp">): Promise<string> {
// Encode the secret as bytes for the signing algorithm
const secret = new TextEncoder().encode(jwtSecret());
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d") // Token expires in 7 days
.sign(secret);
}
export async function verifyToken(token: string): Promise<TokenPayload> {
const secret = new TextEncoder().encode(jwtSecret());
// This throws if the token is invalid or expired
const { payload } = await jwtVerify(token, secret);
return payload as TokenPayload;
}
Update auth/auth.ts to use JWT validation. The auth handler extracts the token from the Authorization header and verifies it.
import { authHandler, Header } from "encore.dev/auth";
import { APIError } from "encore.dev/api";
import { verifyToken } from "./jwt";
export interface AuthData {
userId: string;
email: string;
role: "user" | "admin";
}
interface AuthParams {
authorization: Header<"Authorization">;
}
export const auth = authHandler<AuthParams, AuthData>(
async (params) => {
const header = params.authorization;
// Check for Bearer token format
if (!header?.startsWith("Bearer ")) {
throw APIError.unauthenticated("missing or invalid authorization header");
}
// Extract token after "Bearer "
const token = header.slice(7);
try {
const payload = await verifyToken(token);
return {
userId: payload.userId,
email: payload.email,
role: payload.role,
};
} catch (error) {
// Token is invalid, expired, or tampered with
throw APIError.unauthenticated("invalid token");
}
}
);
Create a login endpoint that verifies credentials and issues tokens. This is the entry point where users exchange their password for a JWT.
First, set up a database for users. Create auth/db.ts:
import { SQLDatabase } from "encore.dev/storage/sqldb";
export const db = new SQLDatabase("auth", {
migrations: "./migrations",
});
Create auth/migrations/001_create_users.up.sql:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Install Argon2 for password hashing. It's the current best practice for password storage, resistant to GPU-based attacks.
npm install @node-rs/argon2
Create auth/login.ts:
import { api, APIError } from "encore.dev/api";
import { generateToken } from "./jwt";
import { db } from "./db";
import { verify } from "@node-rs/argon2";
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
token: string;
userId: string;
}
export const login = api(
{ expose: true, method: "POST", path: "/auth/login" },
async (req: LoginRequest): Promise<LoginResponse> => {
// Find user by email
const user = await db.queryRow<{
id: string;
email: string;
password_hash: string;
role: "user" | "admin";
}>`
SELECT id, email, password_hash, role FROM users WHERE email = ${req.email}
`;
// Don't reveal whether the email exists
if (!user) {
throw APIError.unauthenticated("invalid credentials");
}
// Verify password against stored hash
const valid = await verify(user.password_hash, req.password);
if (!valid) {
throw APIError.unauthenticated("invalid credentials");
}
// Generate JWT token
const token = await generateToken({
userId: user.id,
email: user.email,
role: user.role,
});
return { token, userId: user.id };
}
);
Test the login flow and protected endpoints:
# Login to get a token
curl -X POST http://localhost:4000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret"}'
# Use the token to access protected endpoints
curl http://localhost:4000/profile \
-H "Authorization: Bearer <token-from-login>"
Sessions store auth state on the server, keyed by a session ID in a cookie. This is better for web apps where you control the client, and you want the ability to revoke sessions server-side.
Add a sessions table to track active sessions:
Create auth/migrations/002_create_sessions.up.sql:
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX sessions_user_id ON sessions(user_id);
CREATE INDEX sessions_expires_at ON sessions(expires_at);
Create auth/sessions.ts with functions to create, validate, and delete sessions:
import { db } from "./db";
import { AuthData } from "./auth";
export async function createSession(userId: string): Promise<string> {
// Session expires in 7 days
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const row = await db.queryRow<{ id: string }>`
INSERT INTO sessions (user_id, expires_at)
VALUES (${userId}, ${expiresAt})
RETURNING id
`;
return row!.id;
}
export async function validateSession(sessionId: string): Promise<AuthData | null> {
// Join with users to get user data, check expiration
const row = await db.queryRow<{
user_id: string;
email: string;
role: "user" | "admin";
}>`
SELECT s.user_id, u.email, u.role
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${sessionId}
AND s.expires_at > NOW()
`;
if (!row) {
return null;
}
return {
userId: row.user_id,
email: row.email,
role: row.role,
};
}
export async function deleteSession(sessionId: string): Promise<void> {
await db.exec`DELETE FROM sessions WHERE id = ${sessionId}`;
}
For sessions, read the session ID from cookies instead of the Authorization header:
import { authHandler, Header } from "encore.dev/auth";
import { APIError } from "encore.dev/api";
import { validateSession } from "./sessions";
interface AuthParams {
cookie: Header<"Cookie">;
}
export const auth = authHandler<AuthParams, AuthData>(
async (params) => {
// Parse session ID from cookie header
const cookies = parseCookies(params.cookie ?? "");
const sessionId = cookies["session_id"];
if (!sessionId) {
throw APIError.unauthenticated("no session");
}
// Validate session exists and isn't expired
const user = await validateSession(sessionId);
if (!user) {
throw APIError.unauthenticated("invalid or expired session");
}
return user;
}
);
function parseCookies(cookieHeader: string): Record<string, string> {
const cookies: Record<string, string> = {};
for (const cookie of cookieHeader.split(";")) {
const [name, value] = cookie.trim().split("=");
if (name && value) {
cookies[name] = value;
}
}
return cookies;
}
Use a raw endpoint to set cookies in the response:
import { api } from "encore.dev/api";
import { createSession } from "./sessions";
export const login = api.raw(
{ expose: true, method: "POST", path: "/auth/login" },
async (req, res) => {
// Parse and validate credentials from request body...
const userId = "user-id-from-database";
// Create session in database
const sessionId = await createSession(userId);
// Set secure cookie with session ID
res.setHeader("Set-Cookie", [
`session_id=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${7 * 24 * 60 * 60}`,
]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
}
);
For machine-to-machine communication or third-party integrations, API keys are simpler than JWT. They're long-lived tokens that identify an account or application.
Add a table for API keys:
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL,
name TEXT NOT NULL,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX api_keys_user_id ON api_keys(user_id);
Create auth/api-keys.ts. Never store API keys in plaintext; store a hash and compare hashes during validation.
import { randomBytes, createHash } from "crypto";
import { db } from "./db";
import { AuthData } from "./auth";
export async function createApiKey(userId: string, name: string): Promise<string> {
// Generate a cryptographically random key
const key = randomBytes(32).toString("base64url");
// Store hash of the key (never store the actual key)
const keyHash = createHash("sha256").update(key).digest("hex");
await db.exec`
INSERT INTO api_keys (user_id, key_hash, name)
VALUES (${userId}, ${keyHash}, ${name})
`;
// Return key to user - this is the only time they'll see it
return key;
}
export async function validateApiKey(key: string): Promise<AuthData | null> {
// Hash the provided key to compare with stored hash
const keyHash = createHash("sha256").update(key).digest("hex");
const row = await db.queryRow<{
user_id: string;
email: string;
role: "user" | "admin";
key_id: string;
}>`
SELECT k.id as key_id, k.user_id, u.email, u.role
FROM api_keys k
JOIN users u ON k.user_id = u.id
WHERE k.key_hash = ${keyHash}
`;
if (!row) {
return null;
}
// Track when the key was last used
await db.exec`
UPDATE api_keys SET last_used_at = NOW() WHERE id = ${row.key_id}
`;
return {
userId: row.user_id,
email: row.email,
role: row.role,
};
}
Update the auth handler to accept API keys via a custom header:
import { authHandler, Header } from "encore.dev/auth";
import { APIError } from "encore.dev/api";
import { validateApiKey } from "./api-keys";
interface AuthParams {
apiKey: Header<"X-API-Key">;
}
export const auth = authHandler<AuthParams, AuthData>(
async (params) => {
if (!params.apiKey) {
throw APIError.unauthenticated("missing API key");
}
const user = await validateApiKey(params.apiKey);
if (!user) {
throw APIError.unauthenticated("invalid API key");
}
return user;
}
);
Real-world APIs often need to support multiple authentication methods. You might use JWTs for your web app and API keys for third-party integrations.
interface AuthParams {
authorization: Header<"Authorization">;
apiKey: Header<"X-API-Key">;
}
export const auth = authHandler<AuthParams, AuthData>(
async (params) => {
// Try API key first (simpler, often used by scripts)
if (params.apiKey) {
const user = await validateApiKey(params.apiKey);
if (user) return user;
throw APIError.unauthenticated("invalid API key");
}
// Try JWT bearer token
if (params.authorization?.startsWith("Bearer ")) {
const token = params.authorization.slice(7);
try {
const payload = await verifyToken(token);
return {
userId: payload.userId,
email: payload.email,
role: payload.role,
};
} catch {
throw APIError.unauthenticated("invalid token");
}
}
throw APIError.unauthenticated("no valid authentication provided");
}
);
Once you have authentication, authorization is straightforward. Use the role field in AuthData to restrict endpoints based on user permissions.
import { api, APIError } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
// Admin-only endpoint
export const deleteUser = api(
{ expose: true, auth: true, method: "DELETE", path: "/admin/users/:id" },
async ({ id }: { id: string }): Promise<void> => {
const auth = getAuthData()!;
if (auth.role !== "admin") {
throw APIError.permissionDenied("admin access required");
}
// Delete user...
}
);
For cleaner code, create a reusable helper that throws if the user doesn't have the required role:
import { getAuthData } from "~encore/auth";
import { APIError } from "encore.dev/api";
export function requireAdmin(): void {
const auth = getAuthData();
if (!auth || auth.role !== "admin") {
throw APIError.permissionDenied("admin access required");
}
}
// Usage in any endpoint
export const deleteUser = api(
{ expose: true, auth: true, method: "DELETE", path: "/admin/users/:id" },
async ({ id }: { id: string }): Promise<void> => {
requireAdmin();
// ... rest of handler
}
);
Hash passwords with Argon2 or bcrypt. Never store plaintext passwords. Use a library that handles salting automatically.
Use HTTPS in production. Encore Cloud handles this automatically with managed TLS certificates.
Set secure cookie flags: HttpOnly prevents JavaScript access, Secure requires HTTPS, SameSite prevents CSRF.
Short token expiration for sensitive apps. Use refresh tokens if you need long-lived sessions without storing credentials.
Rate limit auth endpoints to prevent brute force attacks. Consider adding delays after failed attempts.
Log auth failures for security monitoring. Track patterns that might indicate attacks.
Validate input to prevent injection attacks. Use parameterized queries (Encore's db.query does this automatically).
Test your auth endpoints and protected routes to ensure they work correctly:
import { describe, expect, test } from "vitest";
import { login } from "./login";
import { getProfile } from "./profile";
describe("authentication", () => {
test("login returns valid token", async () => {
// Create test user first in beforeEach...
const result = await login({
email: "[email protected]",
password: "password123",
});
expect(result.token).toBeDefined();
expect(result.userId).toBeDefined();
});
test("protected endpoint requires auth", async () => {
// Without auth context, this should fail
await expect(getProfile()).rejects.toThrow();
});
});