← Back to DevBytes

Drizzle ORM Authentication: JWT, Sessions, and OAuth Integration

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles