I’ve spent countless hours integrating user authentication into web applications, and if there’s one thing I’ve learned, it’s that OAuth 2.0 is both a blessing and a curse. It’s the standard for letting users log in with services like GitHub or Google, but getting it right—especially with multiple providers—can be a maze of security concerns and messy code. This challenge is precisely why I sat down to build a cleaner, safer, and more maintainable system. I wanted a solution that was type-safe from the ground up, worked seamlessly at the edge, and didn’t lock me into a specific framework. After much trial and error, I found a powerful combination: Arctic for OAuth flows, Hono for the web server, and Drizzle ORM for the database. Let me show you how it all comes together.
OAuth 2.0 allows your application to access a user’s data from another service without ever seeing their password. Think of it as a valet key for user information. The service, like GitHub, gives your app a limited-access token instead of a master key. This is great for security but introduces complexity. You must handle redirects, token exchanges, and session management. When you add a second or third provider, this complexity multiplies. Have you ever wondered how to keep this process secure without drowning in boilerplate code?
Enter Arctic. It’s a minimal library that handles the OAuth 2.0 authorization code flow, including PKCE, which is a critical security upgrade. Unlike heavier tools, Arctic doesn’t manage your sessions or database. It focuses on one job: talking to OAuth providers. This means you retain full control over your user data and application logic. I appreciate this approach because it fits into any architecture without forcing opinions on you.
Let’s set up a project. I’m using Bun for speed, but Node.js works too. Start by initializing a new project and installing the core dependencies.
bun init -y
bun add hono arctic drizzle-orm @neondatabase/serverless dotenv
bun add -d @types/bun typescript drizzle-kit
Next, create a .env file for your configuration. This keeps secrets out of your code.
DATABASE_URL="postgresql://user:pass@localhost:5432/auth_db"
GITHUB_CLIENT_ID="your_id_here"
GITHUB_CLIENT_SECRET="your_secret_here"
SESSION_SECRET="a-very-long-random-string-here"
APP_URL="http://localhost:3000"
Now, configure your first OAuth provider. Here’s how I set up GitHub with Arctic. Notice how little code is required to get started.
// src/providers/github.ts
import { GitHub } from "arctic";
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!,
`${process.env.APP_URL}/auth/github/callback`
);
With the provider ready, we need a place to store user data. This is where type safety becomes crucial. Using Drizzle ORM, I define a schema that links one user to potentially multiple OAuth accounts. Why is this design important? It allows a person to sign in with both GitHub and Google, linking them to a single profile in your app.
// src/db/schema.ts
import { pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique(),
name: text("name"),
createdAt: timestamp("created_at").defaultNow(),
});
export const oauthAccounts = pgTable("oauth_accounts", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id),
provider: text("provider").notNull(),
providerUserId: text("provider_user_id").notNull(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token"),
expiresAt: timestamp("expires_at"),
}, (table) => {
return {
providerIdx: uniqueIndex("provider_idx").on(table.provider, table.providerUserId),
};
});
This schema ensures each OAuth account is unique per provider. The database will reject duplicate entries, preventing accidental data corruption. What happens, though, when a token expires and needs to be refreshed? We’ll get to that soon.
With the database designed, let’s build the web server using Hono. It’s a lightweight framework that performs well everywhere, from traditional servers to edge runtimes. I start by creating the main app and defining the login route.
// src/index.ts
import { Hono } from "hono";
import { github } from "./providers/github";
const app = new Hono();
app.get("/login/github", async (c) => {
const state = crypto.randomUUID();
const codeVerifier = crypto.randomUUID();
const url = await github.createAuthorizationURL(state, codeVerifier);
// Store state and codeVerifier in a secure session
c.cookie("oauth_state", state, { httpOnly: true, maxAge: 600 });
c.cookie("code_verifier", codeVerifier, { httpOnly: true, maxAge: 600 });
return c.redirect(url.toString());
});
This route redirects the user to GitHub. Notice the state and codeVerifier parameters. The state protects against CSRF attacks, while the code verifier is part of PKCE, ensuring that even if an authorization code is intercepted, it can’t be used without this verifier. Do you see how these small steps build a secure chain?
After the user approves the request, GitHub redirects back to your callback URL. Here, you must validate the state and exchange the code for tokens.
app.get("/auth/github/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const storedState = c.req.cookie("oauth_state");
const codeVerifier = c.req.cookie("code_verifier");
if (!code || !state || state !== storedState || !codeVerifier) {
return c.text("Invalid request", 400);
}
try {
const tokens = await github.validateAuthorizationCode(code, codeVerifier);
const githubUser = await fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
}).then(res => res.json());
// Find or create the user in your database
// This is a simplified example
let user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, githubUser.email),
});
if (!user) {
[user] = await db.insert(users).values({
email: githubUser.email,
name: githubUser.name,
}).returning();
}
// Store the OAuth account
await db.insert(oauthAccounts).values({
userId: user.id,
provider: "github",
providerUserId: githubUser.id.toString(),
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: new Date(Date.now() + tokens.expiresIn * 1000),
});
// Create a session for the user
const sessionId = crypto.randomUUID();
await db.insert(sessions).values({
id: sessionId,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
c.header("Set-Cookie", `session=${sessionId}; HttpOnly; Secure; SameSite=Lax`);
return c.redirect("/dashboard");
} catch (error) {
console.error("OAuth callback error:", error);
return c.redirect("/error");
}
});
This callback does several things: it validates the state, exchanges the code, fetches user profile data, and persists everything to the database. The session ID is set as a secure HTTP-only cookie, which is a good practice to prevent client-side JavaScript from accessing it. But what about managing that session across requests?
I use Hono middleware to protect routes. This middleware runs before each request, checking for a valid session.
// src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
export const authMiddleware = createMiddleware(async (c, next) => {
const sessionId = c.req.cookie("session");
if (!sessionId) {
return c.redirect("/login");
}
const session = await db.query.sessions.findFirst({
where: (sessions, { eq }) => eq(sessions.id, sessionId),
});
if (!session || session.expiresAt < new Date()) {
// Clear invalid session
c.header("Set-Cookie", "session=; Max-Age=0");
return c.redirect("/login");
}
// Attach user to context for downstream use
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, session.userId),
});
c.set("user", user);
await next();
});
Apply this middleware to protected routes. Now, only authenticated users can access them.
app.get("/dashboard", authMiddleware, (c) => {
const user = c.get("user");
return c.html(`<h1>Welcome, ${user.name}</h1>`);
});
Tokens don’t last forever. OAuth access tokens often expire after a short time, requiring a refresh. I handle this by checking expiration before using a token. If it’s expired, I use the refresh token to get a new one.
// src/lib/tokens.ts
export async function refreshAccessToken(account) {
if (account.expiresAt > new Date()) {
return account.accessToken; // Still valid
}
if (!account.refreshToken) {
throw new Error("No refresh token available");
}
const newTokens = await github.refreshAccessToken(account.refreshToken);
await db.update(oauthAccounts)
.set({
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken || account.refreshToken,
expiresAt: new Date(Date.now() + newTokens.expiresIn * 1000),
})
.where(eq(oauthAccounts.id, account.id));
return newTokens.accessToken;
}
This function can be called whenever you need to make an API request on behalf of the user. It keeps the tokens fresh without user intervention. How might you automate this process for all active users? A background job that runs periodically could work, but that’s a topic for another day.
Adding more providers, like Google or Discord, follows the same pattern. Create a new Arctic instance for each and adjust the routes accordingly. The database schema already supports multiple providers, so no changes are needed there. This flexibility is why I chose this setup.
Throughout this process, I’ve made mistakes—like forgetting to validate the state parameter or mishandling token storage. Each error taught me something new about security and robustness. For instance, always use PKCE, even for server-side flows, as it provides an extra layer of protection. And never log tokens or sensitive data; it’s a sure way to compromise security.
Building this system has been a rewarding experience. It’s given me confidence in my authentication layer, knowing that it’s type-safe, secure, and easy to maintain. The tools—Arctic, Hono, and Drizzle ORM—work together beautifully, each playing to its strengths.
I hope this walkthrough helps you in your own projects. Authentication is a critical component, and getting it right pays off in security and user trust. If you have thoughts, questions, or your own tips to share, I’d love to hear them in the comments below. If you found this article useful, please consider liking and sharing it with others who might benefit. Happy coding!
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva