DEV Community

Cover image for Secure Airtable Integration: Mastering OAuth 2.0 PKCE with Node.js 22 and Angular 20
Ingila Ejaz for This is Learning

Posted on

Secure Airtable Integration: Mastering OAuth 2.0 PKCE with Node.js 22 and Angular 20

Integrating third-party services into B2B and SaaS applications demands robust security. When connecting to powerful platforms like Airtable, understanding their authentication mechanisms is paramount. Airtable, embracing modern security standards, utilizes OAuth 2.0 with PKCE (Proof Key for Code Exchange) for its authentication flow, a critical extension that significantly bolsters security.

Pronounced “PIX-y,” PKCE is an ingenious mechanism designed specifically to prevent authorization code interception attacks. This ensures that the application requesting the final access token is indeed the very same one that initiated the authorization process. This “proof-of-possession” is vital for safeguarding user data and maintaining application integrity, especially for public clients like single-page applications or mobile apps.

Let’s break down the core components of PKCE that make this security possible:

  • code_verifier: This is a high-entropy, cryptographically random secret generated by your application at the very beginning of the authorization flow. Think of it as a unique, one-time-use password that your application creates for a single login session. It's never sent directly to the authorization server (Airtable) in the initial request, remaining a secret known only to your application.

  • code_challenge_method: A simple string (e.g., S256) that explicitly communicates to Airtable which hashing algorithm was used to transform the code_verifier. It's essentially your application declaring: "The code_challenge I'm providing was generated using the SHA-256 hashing algorithm." This standardization allows the authorization server to independently verify the challenge.

  • code_challenge: This is the hashed and specially encoded version of your code_verifier. It's the only PKCE parameter sent to Airtable when your application first redirects the user to the authorization endpoint. Airtable securely stores this code_challenge temporarily. When your application later exchanges the authorization code for an access token, it must present the original code_verifier. Airtable will then re-compute the code_challenge from the code_verifier it just received and compare it against the one it initially stored. If they don't match, the token exchange is denied, effectively thwarting any unauthorized attempts. For this tutorial, we leverage Node.js's built-in crypto module, mirroring the robust practices often seen in official documentation and advanced implementations.

In the following sections, we’ll walk through the practical steps of setting up your application to authenticate with Airtable using this secure PKCE flow. We’ll be working with an Airtable base similar to this:

Base for Airtable OAuth

Step 1 — Create OAuth Integrations in Airtable

First things first, create an Airtable Integration:

Register new OAuth Integration

On the next page, give your integration a name and redirect URL. This URL will be redirected when the authentication is successful.

Now click on Register Integration and you will be redirected to another page with your client_id and client_secret.

Register new Integration in Airtable

The client_secret is optional but recommended so go ahead and create a client_secret. Save the client_id in your .env file.

Airtable Client ID Creation

You can select the scope of access as well from the Scopes section. For now, I have selected read-based access only.

Selecting scopes for Airtable Integration

You also need to generate client_secret from the integration panel. To do this, click on Generate client secret.

Generate Client_secret

Click generate on the dialog and copy the client_secret.

Note: the client_secret is only shown once so make sure you copy and paste it in a secure file.

Create client_secret (optional but recommended)

Create client_secret Confirmation

Click on Save Changes and you should now see your integration in the integrations list:

Create new Integration in Airtable

Step 2 — Create a Backend — Node.js 22 + Express

Create New Node.js Project. I am using Node.js 22 but you are free to use any version.

mkdir airtable-integration-backend cd airtable-integration-backend npm init 
Enter fullscreen mode Exit fullscreen mode

Install the following packages as we will be using them later on:

npm install express dotenv cors crypto axios qs #dotenv - For .env files to store client_id, client_secret, redirect_url and scope #express - For making the http calls #cors - For allowing cors (locally) #crypto - To generate code_verifier, code_challenge and code_challenge_method #axios - To call airtable APIs and fetch response #qs - To build queryString 
Enter fullscreen mode Exit fullscreen mode

Next, create a new file called server.js. Your project structure should look something like this.

Node.js project structure

We will create these functions in our server.js file.

get_authorization_url — This function is designed to initiate an OAuth 2.0 authorization flow with Airtable, specifically utilizing the Proof Key for Code Exchange (PKCE) extension for enhanced security.

The primary goal of this function is to generate and return an authorization URL to the client. The client (e.g., a web browser) will then redirect the user to this URL, prompting them to grant permission to access their Airtable data. Here’s a detailed breakdown of this method:

  • Generates state, codeVerifier, and codeChallenge.
  • Stores codeVerifier (and state) in a temporary cache.
  • Constructs the Airtable authorization URL with all the necessary parameters, including state and code_challenge.
  • Sends this URL back to the client.
//Get authorization URL app.get("/get-authorization-url", (req, res) => { // prevents others from impersonating Airtable const state = crypto.randomBytes(100).toString("base64url"); // prevents others from impersonating you const codeVerifier = crypto.randomBytes(96).toString("base64url"); // 128 characters const codeChallengeMethod = "S256"; const codeChallenge = crypto .createHash("sha256") .update(codeVerifier) // hash the code verifier with the sha256 algorithm .digest("base64") // base64 encode, needs to be transformed to base64url .replace(/=/g, "") // remove = .replace(/\+/g, "-") // replace + with - .replace(/\//g, "_"); // replace / with _ now base64url encoded // ideally, entries in this cache expires after ~10-15 minutes authorizationCache[state] = { // we'll use this in the redirect url route codeVerifier, // any other data you want to store, like the user's ID }; // build the authorization URL const authorizationUrl = new URL(`${airtableBaseUrl}/oauth2/v1/authorize`); authorizationUrl.searchParams.set("code_challenge", codeChallenge); authorizationUrl.searchParams.set( "code_challenge_method", codeChallengeMethod ); authorizationUrl.searchParams.set("state", state); authorizationUrl.searchParams.set("client_id", clientId); authorizationUrl.searchParams.set("redirect_uri", redirectUri); authorizationUrl.searchParams.set("response_type", "code"); // your OAuth integration register with these scopes in the management page authorizationUrl.searchParams.set("scope", scope); res.json({ authorizationUrl: authorizationUrl.toString() }); }); 
Enter fullscreen mode Exit fullscreen mode

get_auth_token — This method is the crucial next step in the OAuth 2.0 PKCE flow with Airtable. After a user has granted (or denied) the application permission, Airtable redirects their browser back to this endpoint.

This method is responsible for validating that redirect and then exchanging the received authorization code for actual access tokens. We can also call this as the “token exchange” phase, where the temporary authorization code is swapped for long-lived access and refresh tokens.

The core purpose of this method is to:

  • Validate the incoming redirect from Airtable using the state parameter to prevent CSRF.
  • Handle potential errors from the authorization attempt (e.g., user denied access).
  • Exchange the authorization_code received from Airtable for access_token and refresh_token by making a POST request to Airtable's token endpoint, securely using the code_verifier.
  • Respond to the client with the acquired tokens or an error.
//Get Auth token app.get("/request-oauth-token", (req, res) => { const state = req.query.state; const cached = authorizationCache[state]; if (cached === undefined) { return res .status(400) .json({ error: "This request was not from Airtable!" }); } // clear the cache delete authorizationCache[state]; // Check if the redirect includes an error code. // Note that if your client_id and redirect_uri do not match the user will never be re-directed if (req.query.error) { const error = req.query.error; const errorDescription = req.query.error_description; return res.status(400).json({ error, errorDescription }); } const code = req.query.code; const codeVerifier = cached.codeVerifier; const headers = { // Content-Type is always required "Content-Type": "application/x-www-form-urlencoded", }; if (clientSecret !== "") { // Authorization is required if your integration has a client secret // omit it otherwise headers.Authorization = authorizationHeader; } setLatestTokenRequestState("LOADING"); // make the POST request axios({ method: "POST", url: `${airtableBaseUrl}/oauth2/v1/token`, headers, // stringify the request body like a URL query string data: qs.stringify({ client_id: clientId, code_verifier: codeVerifier, redirect_uri: redirectUri, code, grant_type: "authorization_code", }), }) .then((response) => { // book-keeping so we can show you the response setLatestTokenRequestState("AUTHORIZATION_SUCCESS", response.data); res.json(response.data); }) .catch((e) => { // 400 and 401 errors mean some problem in our configuration, the user waited too // long to authorize, or there were multiple requests using this auth code. // We expect these but not other error codes during normal operations if (e.response && [400, 401].includes(e.response.status)) { setLatestTokenRequestState("AUTHORIZATION_ERROR", e.response.data); res.status(e.response.status).json(e.response.data); } else if (e.response) { console.log("uh oh, something went wrong", e.response.data); setLatestTokenRequestState("UNKNOWN_AUTHORIZATION_ERROR"); res .status(e.response.status) .json({ error: "Unknown error", details: e.response.data }); } else { console.log("uh oh, something went wrong", e); setLatestTokenRequestState("UNKNOWN_AUTHORIZATION_ERROR"); res.status(500).json({ error: "Unknown error", details: e.message }); } }); }); 
Enter fullscreen mode Exit fullscreen mode

Finally, this is how your .env should look like:

.env for Airtable OAuth Backend

Now run the service by executing node server.js and you should see the server running successfully.

Step 3 — Frontend — Angular 20

Once the service is up and running, we create a new Angular app.

ng new airtable-integration-frontend

For my angular app, I have created these basic components:

  • Login — To display the SSO login page
//Template login.html <p>Welcome to Airtable Integration!</p> <button (click)="loginWithAirtable()">Click to Login with SSO</button> //Login.ts import { Component, inject } from '@angular/core'; import { Auth } from '../auth'; @Component({ selector: 'app-login', imports: [], templateUrl: './login.html', styleUrl: './login.css', }) export class Login { private service = inject(Auth); loginWithAirtable() { this.service.getAuthorizationURL().subscribe((response: any) => { window.location.href = response.authorizationUrl; }); } } 
Enter fullscreen mode Exit fullscreen mode
  • AuthCallback — To display a message “Processing OAuth…” while the app is working on generating a token in exchange of code sent by Airtable.
//Template auth-callback.html <p>Performing OAuth...</p> //auth-callback.ts import { Component, inject, OnInit } from '@angular/core'; import { Auth } from '../auth'; import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'app-auth-callback', imports: [], templateUrl: './auth-callback.html', styleUrl: './auth-callback.css', }) export class AuthCallback implements OnInit { private authService = inject(Auth); private router = inject(Router); private route = inject(ActivatedRoute); ngOnInit() { const code = this.route.snapshot.queryParamMap.get('code'); const state = this.route.snapshot.queryParamMap.get('state'); const code_challenge_method = this.route.snapshot.queryParamMap.get( 'code_challenge_method' ); const code_challenge = this.route.snapshot.queryParamMap.get('code_challenge'); if (code && state) { // Exchange code for token (if needed) this.authService.exchangeCodeForToken(code, state).subscribe({ next: (data: any) => { localStorage.clear(); localStorage.setItem('airtable_access_token', data.access_token); localStorage.setItem('airtable_refresh_token', data.refresh_token); localStorage.setItem('airtable_expires_in', data.expires_in); this.router.navigate(['/login-success']); }, error: () => this.router.navigate(['/login-failure']), }); } else { this.router.navigate(['/login-failure']); } } } 
Enter fullscreen mode Exit fullscreen mode
  • LoginSuccess — Protected route and only accessible on successful authentication.
//login-success.html <p>Logged In successfully! This is a route protected by AuthGuard.</p> <button (click)="logout()">Logout</button> //login-success.ts import { Component } from '@angular/core'; @Component({ selector: 'app-login-success', imports: [], templateUrl: './login-success.html', styleUrl: './login-success.css', }) export class LoginSuccess { logout() { localStorage.clear(); window.location.href = '/'; } } 
Enter fullscreen mode Exit fullscreen mode
  • LoginFailure — To redirect to in case of authentication failure. This is optional as you can just simply redirect to the /login page as well.
//login-failure.html <p>Authentication Failed</p> //login-failure.ts import { Component } from '@angular/core'; @Component({ selector: 'app-login-failure', imports: [], templateUrl: './login-failure.html', styleUrl: './login-failure.css', }) export class LoginFailure {} 
Enter fullscreen mode Exit fullscreen mode

Here is what the routes look like:

//app.routes.ts export const routes: Routes = [ { path: '', component: Login, }, { path: 'auth-callback', component: AuthCallback, }, { path: 'login-success', component: LoginSuccess, canActivate: [authGuard], //Protected route via AuthGuard }, { path: 'login-failure', component: LoginFailure, }, ]; 
Enter fullscreen mode Exit fullscreen mode

Service Integration:

We now create a new service called auth-service and integrate our endpoints we created earlier.

Create new service called auth:

ng g s auth

Create the following functions in your service.ts file:

//service.ts import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; @Injectable({ providedIn: 'root', }) export class Auth { private http = inject(HttpClient); API_BASE_URL = 'http://localhost:3000'; getAuthorizationURL() { return this.http.get(`${this.API_BASE_URL}/get-authorization-url`); } exchangeCodeForToken(code: string, state: string) { return this.http.get<any>( `${this.API_BASE_URL}/request-oauth-token?code=${encodeURIComponent( code )}&state=${encodeURIComponent(state)}` ); } } 
Enter fullscreen mode Exit fullscreen mode

We also need to create an authGuard to protect the route. For the sake of the demo, I am only checking if localstorage contains token, but you can be as creative as you want.

import { CanActivateFn } from '@angular/router'; export const authGuard: CanActivateFn = (route, state) => { return localStorage.getItem('airtable_access_token') !== null; }; 
Enter fullscreen mode Exit fullscreen mode

Now run the application using

ng serve --host 127.0.0.1

You should see the following screen:

The full application can be found at my Github.

Frontend: https://github.com/Ingila185/airtable-integration-frontend.git

Backend: https://github.com/Ingila185/airtable-integration-backend.git

Top comments (0)