Understanding Authentication with Drizzle ORM
Drizzle ORM is a modern TypeScript ORM built for performance and flexibility, offering a schema-first approach that works beautifully with edge runtimes and serverless environments. When building any real-world application, authentication becomes a critical layer — managing user identities, sessions, and third‑party sign‑in. This tutorial walks you through implementing three essential authentication strategies directly on top of Drizzle: JWT (JSON Web Tokens), database‑backed sessions, and OAuth integration with providers like GitHub or Google.
We'll cover everything from defining the database tables to securing API routes, while keeping the code practical and production‑ready. By the end you'll have a complete picture of how to combine these approaches in a single codebase using Drizzle ORM as the data layer.
1. Why Authentication Matters with Drizzle
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Authentication verifies who a user is. In a Drizzle‑based project, your authentication layer must seamlessly interact with your database to store users, credentials, sessions, and linked OAuth accounts. Drizzle's type‑safe queries and migration system let you define the exact schema you need, then query it with full TypeScript support — no surprises at runtime. Whether you're building a REST API, a Next.js app, or a backend‑for‑frontend (BFF), the principles stay the same.
2. Project Setup and Schema Design
Begin by installing Drizzle alongside your database driver (we'll use PostgreSQL as an example, but the patterns apply to MySQL or SQLite). Install the necessary packages:
npm install drizzle-orm drizzle-kit pg
npm install -D @types/node ts-node
Define your authentication schema with Drizzle's pg-core (or the corresponding dialect). We'll create tables for users, sessions, and accounts (for OAuth linking). A minimal schema looks like this:
import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core';
// Users table – holds core identity
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').unique().notNull(),
name: text('name'),
passwordHash: text('password_hash'), // for email/password auth
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Sessions table – for session‑based auth
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(), // session token stored in cookie
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// OAuth accounts table – links a user to a third‑party provider
export const accounts = pgTable('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
provider: text('provider').notNull(), // e.g., 'github', 'google'
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Run migrations with drizzle-kit to create these tables. The schema separates concerns: users are the central identity, sessions handle cookie‑based login, and accounts store OAuth tokens. This structure supports multiple authentication methods simultaneously.
3. JWT Authentication with Drizzle
JSON Web Tokens allow stateless authentication — the server issues a signed token containing user information, and the client sends it on every request. Drizzle helps you store user data and validate credentials; the token itself is generated and verified with a library like jsonwebtoken.
3.1 User Registration and Login Endpoints
First, create a utility for password hashing (always use bcrypt or argon2). Then build a registration endpoint that inserts a new user into Drizzle. The login endpoint verifies credentials and returns a JWT.
import { eq } from 'drizzle-orm';
import { db } from './db';
import { users } from './schema';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = '1h';
// Register a new user
async function registerUser(email: string, password: string, name?: string) {
const existing = await db.select().from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error('Email already in use');
}
const passwordHash = await bcrypt.hash(password, 12);
const [newUser] = await db.insert(users).values({
email,
passwordHash,
name: name ?? email,
}).returning();
return { id: newUser.id, email: newUser.email };
}
// Login and issue JWT
async function loginUser(email: string, password: string) {
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user || !user.passwordHash) {
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new Error('Invalid credentials');
const token = jwt.sign(
{ sub: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
return { token, user: { id: user.id, email: user.email, name: user.name } };
}
3.2 Middleware to Protect Routes
On protected routes, verify the token from the Authorization header, then attach the user to the request context. You can fetch the full user from Drizzle if needed, or rely on the token payload for simple checks.
import { Request, Response, NextFunction } from 'express'; // example with Express
interface AuthenticatedRequest extends Request {
user?: { id: string; email: string };
}
function jwtAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed token' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string; email: string };
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
return res.status(401).json({ error: 'Token expired or invalid' });
}
}
JWT authentication works great for APIs and SPAs. The token is short‑lived (e.g., 15‑60 minutes), and you can issue refresh tokens stored in the database for longer sessions — we'll explore that in the session section.
4. Session‑Based Authentication
Session authentication stores a session identifier on the client (usually in an HTTP‑only cookie) and the corresponding session record in the database. Drizzle’s sessions table we defined earlier is perfect for this. It's stateful but gives you immediate revocation and fine‑grained control.
4.1 Creating a Session on Login
After verifying credentials (or after an OAuth flow), create a session record with a unique ID (a random token), set its expiry, and send the token as a cookie. The cookie should be HttpOnly, Secure in production, and use SameSite to prevent CSRF.
import { randomUUID } from 'crypto';
import { db } from './db';
import { sessions } from './schema';
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days in seconds
async function createSession(userId: string): Promise<{ sessionId: string; expiresAt: Date }> {
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
});
return { sessionId, expiresAt };
}
// Example: login handler in Express
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await loginUser(email, password); // returns user object
const { sessionId, expiresAt } = await createSession(user.id);
res.cookie('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt,
path: '/',
});
res.json({ user });
} catch (err) {
res.status(401).json({ error: 'Invalid credentials' });
}
});
4.2 Session Middleware
For every incoming request, read the session cookie, query the database for a valid (non‑expired) session, and fetch the associated user. This middleware populates req.user.
import { eq, and, gt } from 'drizzle-orm';
async function sessionAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const sessionId = req.cookies?.session;
if (!sessionId) {
return res.status(401).json({ error: 'No session cookie' });
}
// Find valid session
const session = await db.query.sessions.findFirst({
where: and(
eq(sessions.id, sessionId),
gt(sessions.expiresAt, new Date())
),
with: {
user: true, // if you define relations
},
});
if (!session || !session.user) {
// Optionally clear cookie if session expired
res.clearCookie('session');
return res.status(401).json({ error: 'Session invalid or expired' });
}
req.user = { id: session.user.id, email: session.user.email, name: session.user.name };
next();
}
To enable relations, update the schema definitions with Drizzle relations. For example, add relations in a separate file so that sessions can include the user.
import { relations } from 'drizzle-orm';
import { users, sessions, accounts } from './schema';
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
accounts: many(accounts),
}));
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
Then use db.query.sessions with with as shown. This is the Drizzle way to perform joins and fetch nested data.
4.3 Logout and Session Cleanup
To log out, simply delete the session from the database and clear the cookie. A scheduled job can periodically remove expired sessions.
async function destroySession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
// Logout route
app.post('/logout', async (req, res) => {
const sessionId = req.cookies?.session;
if (sessionId) {
await destroySession(sessionId);
}
res.clearCookie('session', { path: '/' });
res.json({ success: true });
});
5. OAuth Integration with Drizzle
OAuth lets users sign in with providers like Google, GitHub, or Twitter. The flow involves redirecting the user to the provider, receiving a callback with an authorization code, exchanging it for tokens, and then creating or linking a local user account. Drizzle’s accounts table stores the provider‑specific data and tokens.
5.1 OAuth Flow Overview
We'll demonstrate a generic OAuth 2.0 integration using GitHub as an example. You'll need to register an OAuth app on GitHub and obtain CLIENT_ID and CLIENT_SECRET. The steps are:
- Redirect user to
https://github.com/login/oauth/authorize - User authorizes, GitHub redirects back with a
code - Exchange code for access token (and optionally refresh token)
- Use access token to fetch user profile from GitHub API
- Find or create user in your database, create an account record
- Issue a session or JWT for the authenticated user
5.2 Implementing the OAuth Callback
Use the built‑in fetch API (or a library like oauth) for token exchange. Here's a callback handler that uses Drizzle to persist the OAuth account and user.
import { eq, and } from 'drizzle-orm';
import { db } from './db';
import { users, accounts } from './schema';
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID!;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET!;
const REDIRECT_URI = 'https://yourdomain.com/auth/github/callback';
async function githubCallback(code: string) {
// Exchange code for access token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
}),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) throw new Error(tokenData.error_description);
const accessToken = tokenData.access_token;
// Fetch user profile from GitHub
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}` },
});
const githubUser = await userResponse.json();
// Check if an account already exists for this provider + providerAccountId
const existingAccount = await db.query.accounts.findFirst({
where: and(
eq(accounts.provider, 'github'),
eq(accounts.providerAccountId, String(githubUser.id))
),
with: { user: true },
});
let user: typeof users.$inferSelect;
if (existingAccount && existingAccount.user) {
// Existing user — update tokens if necessary
user = existingAccount.user;
await db.update(accounts)
.set({
accessToken,
refreshToken: tokenData.refresh_token ?? existingAccount.refreshToken,
expiresAt: tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000)
: existingAccount.expiresAt,
})
.where(eq(accounts.id, existingAccount.id));
} else {
// Create a new user and link the account
const [newUser] = await db.insert(users).values({
email: githubUser.email ?? `${githubUser.login}@github.com`, // fallback email
name: githubUser.name ?? githubUser.login,
avatarUrl: githubUser.avatar_url,
}).returning();
await db.insert(accounts).values({
userId: newUser.id,
provider: 'github',
providerAccountId: String(githubUser.id),
accessToken,
refreshToken: tokenData.refresh_token,
expiresAt: tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000)
: undefined,
});
user = newUser;
}
return user;
}
// Express route for callback
app.get('/auth/github/callback', async (req, res) => {
const code = req.query.code as string;
try {
const user = await githubCallback(code);
const { sessionId, expiresAt } = await createSession(user.id);
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
expires: expiresAt,
path: '/',
});
res.redirect('/dashboard'); // or send JSON
} catch (err) {
res.status(500).json({ error: 'OAuth failed' });
}
});
5.3 Handling Multiple Providers
The accounts table is provider‑agnostic. You can add Google, Twitter, etc., by following the same pattern. Simply use the provider's token endpoint and user info URL. Drizzle’s schema handles the linking, allowing a single user to connect multiple providers.
6. Combining JWT, Sessions, and OAuth
In a real application, you often need to support multiple authentication methods. For example, mobile apps might use JWT for stateless API calls, while the web frontend relies on session cookies. OAuth is used for social sign‑in, but still results in a session or JWT. A unified strategy ensures a single user identity across all methods.
6.1 Unified Auth Middleware
You can write a middleware that checks for both a session cookie and a JWT in the Authorization header, giving priority to the session if both exist. This approach works well for hybrid apps.
async function unifiedAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
// 1. Try session cookie first
const sessionId = req.cookies?.session;
if (sessionId) {
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), gt(sessions.expiresAt, new Date())),
with: { user: true },
});
if (session?.user) {
req.user = { id: session.user.id, email: session.user.email };
return next();
}
}
// 2. Fallback to JWT
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string; email: string };
// Optional: double‑check user still exists in DB
const user = await db.query.users.findFirst({ where: eq(users.id, payload.sub) });
if (user) {
req.user = { id: user.id, email: user.email };
return next();
}
} catch (err) {
// Token invalid — continue to fail
}
}
return res.status(401).json({ error: 'Unauthorized' });
}
6.2 Refresh Token Strategy
For long‑lived JWT access, store refresh tokens in a database table (similar to sessions) and rotate them on each use. When the JWT expires, the client sends the refresh token to obtain a new JWT without re‑entering credentials. Drizzle handles the storage and revocation elegantly.
// Example refresh token table (add to schema)
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tokenHash: text('token_hash').notNull(), // store hashed value
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
The refresh endpoint verifies the token, checks it hasn't expired, rotates it (delete old, insert new), and issues a new JWT. This keeps the user logged in securely.
7. Best Practices and Security Considerations
- Hash passwords with bcrypt or argon2; never store plaintext.
- Use HTTP‑only, Secure cookies for session tokens to prevent XSS theft.
- Implement CSRF protection when using cookies — SameSite=Lax helps, but consider explicit tokens for sensitive mutations.
- Short‑lived JWTs (15–30 minutes) combined with refresh tokens stored in the DB give you control over revocation.
- Rotate refresh tokens on each use; detect reuse as potential theft.
- Validate OAuth state parameters to prevent CSRF in the OAuth flow.
- Store minimal claims in JWT; keep the payload small and avoid sensitive data.
- Use Drizzle’s
withand relations to efficiently fetch user data with sessions/accounts, avoiding N+1 queries. - Index frequently queried columns like
sessions.expires_atandaccounts.provider_account_idfor performance. - Periodically clean up expired sessions and tokens with a background job (e.g.,
pg_cronor a simple scheduled function).
8. Conclusion
Drizzle ORM provides a solid, type‑safe foundation for implementing authentication. By carefully designing your schema — with separate tables for users, sessions, and OAuth accounts — you can seamlessly support JWT, session cookies, and third‑party sign‑in. The patterns shown here are directly usable in Express, Next.js, or any Node.js framework, and scale naturally as your application grows. Remember that security is an ongoing process: keep secrets safe, rotate tokens, and always validate input. With Drizzle handling your database interactions, you can focus on building great user experiences while staying confident that your auth layer is robust and maintainable.