In this tutorial, we show how to build a sample application with passkey authentication using Ruby on Rails in the backend together with a React frontend.
Nicolai
Created: October 17, 2023
Updated: February 17, 2025
2. Ruby on Rails passkey project prerequisites
3. Repository structure for Ruby on Rails passkey project
4. Set up your Corbado account and project
5. Set up Ruby on Rails passkeys app
5.2. Deliver React build files with Ruby
5.3. Configure environment variables
6. Create React passkey login page
6.1. Embed the passkey authentication UI component
6.2. Add route for passkey login page
7.1. Create Ruby on Rails endpoint
7.3. Get data from Corbado session
7.4. Create React component for profile page
7.5. Add route for profile page
8. Start using passkeys with our Ruby on Rails - React implementation
In this blog post, well be walking through the process of building a sample application with passkey authentication using Ruby on Rails in the backend together with a React frontend. To make passkeys work, we use Corbado's passkey-first UI components that automatically connects to a passkeys backend.
If you want to see the finished code, please have a look at our sample application GitHub repository.
The result looks as follows:
This tutorial assumes basic familiarity with Ruby on Rails, React, Typescript and HTML. Furthermore, make sure you have Ruby, Rails as well as Node.js installed and accessible from your shell. Lets dive in!
Our Ruby on Rails + React project contains many files, but these are the most important ones:
... ├── app | ... | ├── controllers | | └── pages_controller.rb # Controller for our pages | | ├── config | ... | ├── environments | | ├── development.rb # Development environment config | | └── production.rb # Production environment config | | | └── routes.rb # The Ruby on Rails routes are configured here | └── frontend ... ├── .env └── src ... ├── index.js # Root of our React.js app which also contains the React.js routes └── routes ├── login.js # Login page containing the Corbado webcomponent └── profile.js # Profile page showing information about the current user
Visit the Corbado developer panel to sign up and create your account (you'll see a passkey sign-up in action here!).
In the project setup wizard, begin by selecting an appropriate name for your project. For the product selection, opt for "Corbado Complete". Subsequently, specify your technology stack and select "DEV along with "Corbado session management" options. Afterwards, you'll get more foundational setup guidance.
Next, choose "Web app" as an application type and React as your framework. In the application settings, define your application url and relying party id as follows:
To initialize our project, we create a new Ruby on Rails project with
rails new ruby-react
This will create a ruby-react folder containing our Ruby on Rails project. Now, we can move on to create our frontend which will be delivered by Ruby in step 5.2.
We head into our project with
cd ruby-react
and initialize our React frontend:
npx create-react-app frontend
We will bundle everything (all pages, styles and code) into a single HTML file for Ruby to deliver.
In the "frontend" folder, create a file "webpack.config.js" with the following content:
const Dotenv = require("dotenv-webpack"); module.exports = { plugins: [new Dotenv()], };
Make sure the contents of the scripts and config folders match the content of our sample frontend's respective folder. Also adjust your package.json file to match the one in the repo here to make sure you have all necessary dependencies (You can copy it and just change the app name).
If you now execute
npm install && npm run build
and open the build/index.html file you can see that our entire React app is packaged into that single HTML file.
We want to deliver the HTML file we just produced with Ruby. We create a file called pages_controller.rb under app/controllers and give it the following content:
require 'net/http' require 'json' require 'jwt' class PagesController < ActionController::Base def manifest render file: 'frontend/build/manifest.json' end def home render file: 'frontend/build/index.html' end end
The "home" method sends the index.html that Is generated by React to the user. React has its own internal router which is included in our HTML file, so we can just always deliver this generated HTML file and it will display the correct page dependent on the current URL.
Next, we create a route in /config/routes.rb for the methods inside pages_controller.rb.
Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Defines the root path route ("/") # root "articles#index" match "/manifest.json", to: "pages#manifest", via: :get match "/", to: "pages#home", via: :get match "*path", to: "pages#home", via: :get end
This basically always sends back the HTML file which was generated by React.
We will need the Corbado project ID in the next steps, so we'll add it to our environment variables. We create an environment variable for Ruby and then one for React. Concerning Ruby, we add our variable to the development.rb and production.rb files under /config/environments/:
require "active_support/core_ext/integer/time" Rails.application.configure do # ... config.corbado_project_id = "pro-xxx" en
Concerning React, we have installed dotenv in step 5.1 (When you adjusted the package.json file). Therefore we can just create a .env file in the React project root (/frontend) and paste our project id there:
REACT_APP_CORBADO_PROJECT_ID=pro-xxx
First, we'll create our login page under /frontend/src/routes/login.js with the content below. It contains only the Corbado UI component which will handle authentication for us. On successful signup/ login, we'll redirect the user to the profile page.
import {CorbadoAuth} from "@corbado/react"; import {useNavigate} from "react-router"; function Login() { const navigate = useNavigate() return ( <div> <CorbadoAuth onLoggedIn={() => {navigate("/profile")}} /> </div> ); } export default Login;
To make it work we modify index.js in our /frontend folder to include the react router with a route for /login.
import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import reportWebVitals from "./reportWebVitals"; import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider, } from "react-router-dom"; import Login from "./routes/login"; import {CorbadoProvider} from "@corbado/react"; const router = createBrowserRouter( createRoutesFromElements( <Route path="/"> <Route index path="/" element={<Navigate to="/login"/>}></Route> <Route path="/login" element={<Login/>}></Route> </Route> ) ); window.addEventListener("DOMContentLoaded", function (e) { ReactDOM.createRoot(document.getElementById("root")).render( <CorbadoProvider projectId={process.env.REACT_APP_CORBADO_PROJECT_ID}> <RouterProvider router={router}/> </CorbadoProvider> ); reportWebVitals(); });
Notice how we wrapped our entire application with the CorbadoProvider component. We have to pass it our project id, which we retrieve from our environment. Furthermore, we'll set the setShortSessionCookie option to true so that a cookie is created on login.
After successful authentication, the Corbado UI component redirects the user to the provided Redirect URL (http://localhost:3000/profile). This page displays information about the user which our frontend (React) will get from our backend (Ruby on Rails). We will now create an endpoint which will provide that info.
We first add a route in config/routes.rb:
Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Defines the root path route ("/") # root "articles#index" match "/api/profile", to: "pages#api_profile", via: :get match "/manifest.json", to: "pages#manifest", via: :get match "/", to: "pages#home", via: :get match "*path", to: "pages#home", via: :get end
and then we create the corresponding method in app/controllers/pages_controller.rb:
require 'net/http' require 'json' require 'jwt' class PagesController < ActionController::Base def manifest render file: 'frontend/build/manifest.json' end def home render file: 'frontend/build/index.html' end def api_profile end end
In our backend, we will get the info from the Corbado session, but before we can use information embedded in the session, we need to verify that the session is valid. We therefore take the cbo_short_session cookie (the session) and verify its signature using the public key from Corbado. We also verify that the issuer is correct (use your Corbado project ID here):
require 'net/http' require 'json' require 'jwt' class PagesController < ActionController::Base def manifest render file: 'frontend/build/manifest.json' end def home render file: 'frontend/build/index.html' end def api_profile @project_id = Rails.application.config.corbado_project_id @user_id = session[:user_id] @user_name = session[:user_name] @user_email = session[:user_email] issuer = "https://#{@project_id}.frontendapi.corbado.io" jwks_uri = "https://#{@project_id}.frontendapi.corbado.io/.well-known/jwks" begin # Fetch JSON from the jwks_uri uri = URI(jwks_uri) response = Net::HTTP.get(uri) json = JSON.parse(response) # Get the public key publicKey = JSON.parse(json["keys"].first.to_json) # Verify the JWT token verifier = JWT::Verify.new({}, iss: issuer, verify_iat: true, algorithms: ["RS256"], jwks: publicKey ) cboShortSession = cookies[:cbo_short_session] decoded_token = JWT.decode(cboShortSession, nil, false, verifier: verifier).first # Check if the token is valid unless decoded_token return render json: { :error => "JWT token is not valid!" }.to_json, status: 400 end return render json: {}.to_json rescue => e puts e.message return render json: { :error => e.message }.to_json, status: 400 end end end
Finally, we can extract the information stored in the JWT claims. These are then sent to our frontend.
# Extract information from the token @user_id = decoded_token["sub"] @user_name = decoded_token["name"] @user_email = decoded_token["email"] return render json: { :user_id => @user_id, :user_name => @user_name, :user_email => @user_email }.to_json
In the /frontend/routes folder, add a profile.js file with the following content:
import axios from "axios"; import {useCorbado} from "@corbado/react"; import {useState} from "react"; import {useNavigate} from "react-router-dom"; function Profile() { const [userID, setUserID] = useState("loading..."); const [userName, setUserName] = useState("loading..."); const [userEmail, setUserEmail] = useState("loading..."); const [checked, setChecked] = useState(false); const navigate = useNavigate() const { logout } = useCorbado() async function handleLogout() { console.log("Logout") try { await logout() } finally { navigate("/login") } } if (!checked) { axios .get(window.location.origin + "/api/profile") .then((response) => { console.log(response.data); setUserID(response.data.user_id); setUserName(response.data.user_name); setUserEmail(response.data.user_email); }) .catch((error) => { console.log(error); setUserID("Unauthorized"); setUserName("Unauthorized"); setUserEmail("Unauthorized"); }); setChecked(true); } return ( <div> <h2>:/protected 🔒</h2> <p>User ID: {userID}</p> <p>Name: {userName}</p> <p>Email: {userEmail}</p> <button id="logoutButton" onClick={handleLogout}> Logout </button> </div> ); }; export default Profile;
Next, also add a /profile route inside index.js:
import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import reportWebVitals from "./reportWebVitals"; import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider, } from "react-router-dom"; import Login from "./routes/login"; import Profile from "./routes/profile"; import {CorbadoProvider} from "@corbado/react"; const router = createBrowserRouter( createRoutesFromElements( <Route path="/"> <Route index path="/" element={<Navigate to="/login" />}></Route> <Route path="/profile" element={<Profile />}></Route> <Route path="/login" element={<Login />}></Route> </Route> ) ); window.addEventListener("DOMContentLoaded", function (e) { ReactDOM.createRoot(document.getElementById("root")).render( <CorbadoProvider projectId={process.env.REACT_APP_CORBADO_PROJECT_ID} setShortSessionCookie={true}> <RouterProvider router={router} /> </CorbadoProvider> ); reportWebVitals(); });
To start our application, we first rebuild our React app by executing
npm run build
inside the "/frontend" directory. Afterwards we go back to the project root and start our Ruby on Rails app with
./bin/rails s
When visiting http://localhost:3000 you should see the following screen:
After successful sign up / login, you see the profile page:
This tutorial showed how easy it is to add passwordless authentication with passkeys to a Ruby on Rails app with a React frontend using Corbado. Besides the passkey-first authentication, Corbado provides simple session management, that we used for a retrieval of basic user data. If you want to read more about how Corbado's session management please check the docs here.
Table of Contents
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Tutorial: How to Add Passkeys to Node.js (Express) App
Lukas - October 16, 2023
Flask Passkeys: How to Implement Passkeys with Python Flask
Janina - September 15, 2023