js

Building a Secure OAuth 2.0 and OpenID Connect System with AdonisJS

Learn how to implement OAuth 2.0, OpenID Connect, and social logins in AdonisJS with PostgreSQL for secure user authentication.

Building a Secure OAuth 2.0 and OpenID Connect System with AdonisJS

I’ve been building web applications for years, and one challenge keeps coming back: authentication. It’s not just about logging users in. It’s about doing it securely, supporting different login methods, and managing what users can do once they’re inside. This is why I want to talk about OAuth 2.0 and OpenID Connect with AdonisJS. If you’ve ever felt overwhelmed by terms like “authorization code flow” or “token refresh,” you’re not alone. Let’s build this system together, step by step.

Why focus on this now? Modern applications rarely live in isolation. Users expect to sign in with Google or GitHub. Your mobile app, web app, and API all need to share login state securely. A poorly built auth system is a major security risk. Getting it right from the start saves countless headaches later.

We’ll create a system that handles it all: your own login, social logins, and fine-grained permissions. We’ll use AdonisJS for its powerful, structured approach and PostgreSQL for reliable data storage. Ready to build something robust?

First, let’s set up our project. Create a new AdonisJS API application with PostgreSQL support.

npm init adonisjs@latest adonis-oauth-system -- --kit=api --db=postgres
cd adonis-oauth-system

We need a few key packages. Install them with npm.

npm install @adonisjs/auth @adonisjs/ally jsonwebtoken nanoid bcrypt

The @adonisjs/ally package is crucial. It provides a clean, unified interface for social authentication. Think of it as a translator between your app and providers like Google.

Now, configure the database. Open config/database.ts. Ensure your PostgreSQL connection details are correct and sourced from environment variables for security.

// config/database.ts excerpt
connections: {
  postgres: {
    client: 'pg',
    connection: {
      host: env.get('DB_HOST', 'localhost'),
      port: env.get('DB_PORT', 5432),
      user: env.get('DB_USER', 'postgres'),
      password: env.get('DB_PASSWORD', ''),
      database: env.get('DB_DATABASE', 'adonis_oauth'),
    },
    migrations: { naturalSort: true },
  }
}

With the foundation ready, we design our database. What tables do we truly need? We need a users table, of course. But for OAuth, we also need tables for oauth_clients, oauth_tokens, and authorization_codes. Let’s create the users migration first.

Run node ace make:migration users. This creates a file in database/migrations/. Open it and define the schema.

// database/migrations/xxxx_create_users_table.ts
export default class extends BaseSchema {
  protected tableName = 'users'
  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').primary()
      table.string('email').notNullable().unique()
      table.string('password').nullable() // Nullable for social login users
      table.string('full_name').notNullable()
      table.string('provider').nullable() // e.g., 'google', 'github'
      table.string('provider_id').nullable() // Their ID from that provider
      table.boolean('email_verified').defaultTo(false)
      table.timestamps(true)
    })
  }
}

Notice the password field is nullable. Why is that important? A user who signs up with Google doesn’t have a password in your system. Their authentication is handled by Google. Your system just needs to recognize them.

Next, we model the OAuth clients. These are applications that want to use your authentication service. It could be your own frontend, a mobile app, or a third-party service. Create the migration: node ace make:migration oauth_clients.

// database/migrations/xxxx_create_oauth_clients_table.ts
async up() {
  this.schema.createTable(this.tableName, (table) => {
    table.increments('id').primary()
    table.string('client_id').notNullable().unique()
    table.string('client_secret').notNullable()
    table.string('name').notNullable()
    table.json('redirect_uris').notNullable() // Array of allowed URLs
    table.json('scopes').notNullable() // What permissions this client can ask for
    table.boolean('is_active').defaultTo(true)
    table.timestamps(true)
  })
}

The redirect_uris and scopes are JSON columns. This allows us to store arrays of data easily. A client might be allowed to redirect to https://app.example.com/callback and request scopes like read:profile and write:posts.

Have you considered what happens during the login handshake? The authorization code flow is the most common and secure for web apps. It involves a temporary code. We need a table to store these codes before they’re exchanged for a token.

Create the authorization codes table: node ace make:migration oauth_authorization_codes.

// database/migrations/xxxx_create_oauth_authorization_codes_table.ts
async up() {
  this.schema.createTable(this.tableName, (table) => {
    table.increments('id').primary()
    table.string('code').notNullable().unique()
    table.integer('user_id').unsigned().references('users.id')
    table.integer('client_id').unsigned().references('oauth_clients.id')
    table.json('scopes').notNullable()
    table.string('redirect_uri').notNullable()
    table.timestamp('expires_at').notNullable()
    table.boolean('revoked').defaultTo(false)
    table.timestamps(true)
    table.index(['code', 'expires_at']) // For faster lookups
  })
}

This code is short-lived, perhaps valid for only 10 minutes. It’s a key part of the security, preventing the code from being reused if intercepted.

Now, let’s talk about the core of the system: the tokens. When a user logs in, they get an access token. This token is like a keycard. It tells your API, “This person has permission to be here.” We also issue a refresh token. This is a special token used to get a new access token when the old one expires, without making the user log in again.

Create the tokens table: node ace make:migration oauth_access_tokens.

// database/migrations/xxxx_create_oauth_access_tokens_table.ts
async up() {
  this.schema.createTable(this.tableName, (table) => {
    table.increments('id').primary()
    table.string('token').notNullable().unique()
    table.integer('user_id').unsigned().references('users.id')
    table.integer('client_id').unsigned().references('oauth_clients.id')
    table.json('scopes').notNullable()
    table.boolean('is_revoked').defaultTo(false)
    table.timestamp('expires_at').notNullable()
    table.string('refresh_token').nullable().unique()
    table.timestamp('refresh_token_expires_at').nullable()
    table.timestamps(true)
  })
}

Storing the refresh token alongside the access token allows us to manage token rotation. This is a security practice where a used refresh token is invalidated and replaced with a new one. Can you see how this limits the damage if a token is stolen?

With our database designed, let’s implement the OAuth service. This will be the brain of our operation. Create a new file: app/Services/OAuthService.ts.

We’ll start with a method to generate authorization codes. This happens when a user approves a client’s request.

// app/Services/OAuthService.ts
import OauthAuthorizationCode from '#models/oauth_authorization_code'
import { nanoid } from 'nanoid'

export default class OAuthService {
  async createAuthorizationCode(userId: number, clientId: number, scopes: string[], redirectUri: string) {
    const code = nanoid(40) // Generate a secure random code
    const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes

    const authCode = await OauthAuthorizationCode.create({
      code,
      userId,
      clientId,
      scopes,
      redirectUri,
      expiresAt,
    })

    return authCode.code
  }
}

The nanoid package creates URL-friendly, cryptographically strong random strings. It’s a better choice than simple random numbers for security codes.

The next step is exchanging that code for tokens. This is where the client makes a back-channel request to your server, presenting the code.

// app/Services/OAuthService.ts continued
import OauthAccessToken from '#models/oauth_access_token'
import OauthClient from '#models/oauth_client'
import db from '@adonisjs/lucid/services/db'

async exchangeCodeForToken(code: string, clientId: string, clientSecret: string) {
  // Start a database transaction for safety
  const trx = await db.transaction()

  try {
    // 1. Find the valid, unrevoked code
    const authCode = await OauthAuthorizationCode.query({ client: trx })
      .where('code', code)
      .where('revoked', false)
      .where('expires_at', '>', new Date())
      .preload('client')
      .firstOrFail()

    // 2. Verify the client credentials match
    if (authCode.client.clientId !== clientId || authCode.client.clientSecret !== clientSecret) {
      throw new Error('Invalid client credentials')
    }

    // 3. Create access and refresh tokens
    const accessToken = nanoid(60)
    const refreshToken = nanoid(60)
    const accessTokenExpiry = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
    const refreshTokenExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days

    await OauthAccessToken.create({
      token: accessToken,
      userId: authCode.userId,
      clientId: authCode.clientId,
      scopes: authCode.scopes,
      expiresAt: accessTokenExpiry,
      refreshToken: refreshToken,
      refreshTokenExpiresAt: refreshTokenExpiry,
    }, { client: trx })

    // 4. Revoke the authorization code so it can't be used again
    authCode.revoked = true
    await authCode.save()

    // Commit the transaction
    await trx.commit()

    return {
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: 3600,
      refresh_token: refreshToken,
      scope: authCode.scopes.join(' '),
    }
  } catch (error) {
    await trx.rollback()
    throw error
  }
}

This method does several critical things. It finds the code, checks it’s still valid, verifies the client is who they say they are, creates tokens, and immediately revokes the used code. All within a database transaction. Why is the transaction important? It ensures that if any step fails, everything rolls back. We avoid a situation where tokens are created but the code isn’t revoked.

Now, let’s add social login. This is where @adonisjs/ally shines. First, configure a provider like Google in config/ally.ts.

// config/ally.ts
import env from '#start/env'
import { defineConfig } from '@adonisjs/ally'

const allyConfig = defineConfig({
  google: {
    driver: 'google',
    clientId: env.get('GOOGLE_CLIENT_ID'),
    clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
    callbackUrl: 'http://localhost:3333/auth/google/callback',
    scopes: ['email', 'profile'],
  },
})

export default allyConfig

Next, create a controller to handle the social login flow. Run node ace make:controller AuthController.

// app/Controllers/Http/AuthController.ts
import { HttpContext } from '@adonisjs/core/http'
import ally from '@adonisjs/ally/services/main'

export default class AuthController {
  async redirect({ ally, params }: HttpContext) {
    return ally.use(params.provider).redirect() // e.g., 'google'
  }

  async callback({ ally, params, response }: HttpContext) {
    const provider = ally.use(params.provider)

    // Check for errors (like user denied access)
    if (provider.hasError()) {
      return response.badRequest(provider.getError())
    }

    const socialUser = await provider.user()

    // Find or create a user in our database
    const user = await User.firstOrCreate(
      { email: socialUser.email },
      {
        email: socialUser.email,
        fullName: socialUser.name,
        provider: params.provider,
        providerId: socialUser.id,
        emailVerified: socialUser.emailVerificationState === 'verified',
      }
    )

    // Here, you would typically create a session or OAuth tokens
    // For example, generate a JWT for this user
    const token = await this.generateUserToken(user)

    return response.ok({ user, token })
  }

  private async generateUserToken(user: User) {
    // Use AdonisJS Auth JWT driver or your OAuthService
    // This is a simplified example
    const jwt = require('jsonwebtoken')
    return jwt.sign({ userId: user.id }, process.env.APP_KEY, { expiresIn: '1h' })
  }
}

The redirect method sends the user to Google. The callback method handles the response. The ally.user() method gives us a standardized object with the user’s email, name, and ID from Google. We then find or create a corresponding user in our database.

But how do we protect our API endpoints? We need middleware. Create a new middleware file: node ace make:middleware OAuth.

// app/Middleware/OAuthMiddleware.ts
import { HttpContext } from '@adonisjs/core/http'
import OauthAccessToken from '#models/oauth_access_token'

export default class OAuthMiddleware {
  async handle(ctx: HttpContext, next: () => Promise<void>) {
    const authHeader = ctx.request.header('authorization')
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return ctx.response.unauthorized({ error: 'Missing or invalid token' })
    }

    const token = authHeader.substring(7) // Remove 'Bearer ' prefix

    // Find the valid, unrevoked token
    const accessToken = await OauthAccessToken.query()
      .where('token', token)
      .where('is_revoked', false)
      .where('expires_at', '>', new Date())
      .preload('user')
      .first()

    if (!accessToken) {
      return ctx.response.unauthorized({ error: 'Invalid or expired token' })
    }

    // Attach the user and token info to the context for use in controllers
    ctx.user = accessToken.user
    ctx.accessToken = accessToken

    await next()
  }
}

This middleware runs before your controller. It checks for the Authorization: Bearer <token> header. It looks up the token in the database to ensure it’s valid and not expired. If everything checks out, it attaches the user to the request context and lets the request proceed.

You can register this middleware globally or on specific routes. In start/kernel.ts, you might add it to the named middleware collection.

// start/kernel.ts
export const middleware = router.named({
  // ... other middleware
  auth: () => import('#middleware/o_auth_middleware')
})

Then, protect a route in your start/routes.ts file.

// start/routes.ts
import router from '@adonisjs/core/services/router'

router.get('/api/profile', '#controllers/profile_controller.index').middleware('auth')

Now, any request to /api/profile must include a valid OAuth access token. Your ProfileController can safely assume ctx.user exists.

What about refreshing an expired token? That’s the job of the refresh token. We need an endpoint for that.

// app/Controllers/Http/TokenController.ts
import { HttpContext } from '@adonisjs/core/http'
import OauthAccessToken from '#models/oauth_access_token'
import db from '@adonisjs/lucid/services/db'

export default class TokenController {
  async refresh({ request, response }: HttpContext) {
    const { refresh_token } = request.body()

    const trx = await db.transaction()
    try {
      // Find the valid refresh token
      const oldToken = await OauthAccessToken.query({ client: trx })
        .where('refresh_token', refresh_token)
        .where('refresh_token_expires_at', '>', new Date())
        .where('is_revoked', false)
        .firstOrFail()

      // Revoke the old tokens (security: refresh token rotation)
      oldToken.isRevoked = true
      oldToken.refreshToken = null
      await oldToken.save()

      // Issue new tokens
      const newAccessToken = nanoid(60)
      const newRefreshToken = nanoid(60)

      await OauthAccessToken.create({
        token: newAccessToken,
        userId: oldToken.userId,
        clientId: oldToken.clientId,
        scopes: oldToken.scopes,
        expiresAt: new Date(Date.now() + 3600 * 1000),
        refreshToken: newRefreshToken,
        refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
      }, { client: trx })

      await trx.commit()

      return response.ok({
        access_token: newAccessToken,
        refresh_token: newRefreshToken,
        token_type: 'Bearer',
        expires_in: 3600,
      })
    } catch {
      await trx.rollback()
      return response.unauthorized({ error: 'Invalid refresh token' })
    }
  }
}

This implements refresh token rotation. The old refresh token is immediately revoked when used. The client gets a brand new access token and a new refresh token. This limits the window of time a stolen refresh token can be abused.

Building authentication is a significant task, but breaking it down into these components makes it manageable. We’ve covered the database design, the core OAuth service, social logins, protecting routes, and token refresh. Each part builds on the last to create a complete, secure system.

The key is to start simple. Get a basic code flow working. Then add social logins. Then implement token refresh. Test each piece thoroughly. Security is not a feature you add at the end; it’s built into each step.

I hope this guide gives you a clear path forward. Authentication doesn’t have to be a mystery. With the right structure and tools, you can build a system that is both secure and user-friendly. What part of this process are you most excited to implement in your own project?

If you found this walkthrough helpful, please share it with other developers who might be facing the same challenges. Have you built an OAuth system before? What was your biggest lesson? Let me know in the comments below—I’d love to hear about your experiences and answer any questions you have.


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

Keywords: adonisjs,oauth 2.0,openid connect,social login,authentication



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe, scalable web applications. Build modern full-stack apps with seamless database operations.

Blog Image
Build Complete Event-Driven Microservices Architecture with NestJS, RabbitMQ, MongoDB: Step-by-Step Tutorial

Learn to build event-driven microservices with NestJS, RabbitMQ & MongoDB. Master saga patterns, error handling, monitoring & deployment for scalable systems.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Full-Stack Development

Learn to integrate Next.js with Prisma ORM for type-safe full-stack development. Build React apps with seamless database management and SSR capabilities.

Blog Image
Simplify Real-Time App Development with Feathers.js and ArangoDB

Discover how combining Feathers.js and ArangoDB streamlines real-time apps with a unified, multi-model data architecture.

Blog Image
Build Type-Safe GraphQL APIs: Complete TypeGraphQL, Prisma & PostgreSQL Guide for Modern Developers

Learn to build type-safe GraphQL APIs with TypeGraphQL, Prisma & PostgreSQL. Step-by-step guide covering setup, schemas, resolvers, testing & deployment.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Applications with Modern Database ORM

Learn how to integrate Next.js with Prisma ORM for type-safe, scalable web apps. Master database interactions, schema management, and boost developer productivity.