Integration guide
Add sign-in to your app, one chapter at a time
Follow one visitor from the moment they land on your sign-in page to the moment they're inside your app. Each chapter tells you what happens, what you build, and which API is involved.
How a sign-in travels
Four parties take part: your visitor, your page, Aathorize, and your server. Here is the whole trip — pick a stop to jump to its chapter.
- 01
Your page shows the widget
A sign-in card with your logo appears.
You add it once - 02
Your visitor signs in
Google, an email code, WhatsApp…
Shield handles it - 03
They come back to you
With an access_token in the URL.
Automatic redirect - 04
Your server asks who it is
GET /api/auth/user
Your one API call - 05
You open the door
Start a session, show your app.
Your code
Only chapter 4 is an API call you make yourself. The widget makes every other call for you.
Prologue
Set the scene in the dashboard
Before any code, tell Aathorize four things about your app. It takes about two minutes.
Choose sign-in methods
Google, Facebook, LinkedIn, email code or WhatsApp. Only active methods appear on the card.
ProvidersCreate an API key
You get a public key (pk_…) for the browser and a secret for your server. The
secret is shown once — store it as AATHORIZE_API_SECRET.
Allow your callback URL
Add the origin of your redirect_uri, e.g. https://yourapp.com. An empty
list allows any URL — lock it before launch.
Chapter 1 · The welcome mat
Put the sign-in widget on your page
Your visitor clicks Sign in and lands on your login page. Instead of a form you have
to build, secure and maintain, they're greeted by a sign-in card with your logo on it. That card is <shield-auth> — a standard web component, so it works with every framework, or none at
all.
<shield-auth> element where the card should appear, and load the script once.
The script URL already carries your public key, so there is nothing else to configure.No build step, no framework. Paste this into the page where people sign in.
<!doctype html>
<html lang="en">
<body>
<main class="sign-in">
<!-- 1. The stage: Shield draws the sign-in card here -->
<shield-auth
theme="light"
redirect_uri="https://yourapp.com/auth/callback"
></shield-auth>
</main>
<!-- 2. The actor: the widget, already bound to your public key -->
<script src="https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js" async></script>
</body>
</html>Render the element like any tag and load the script once when the page mounts. The type file stops TypeScript from complaining about an unknown tag.
import { useEffect } from "react";
const SHIELD_SRC = "https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js";
export default function SignIn() {
useEffect(() => {
// Load the widget once, even if this page mounts again.
if (document.querySelector(`script[src="${SHIELD_SRC}"]`)) return;
const script = document.createElement("script");
script.src = SHIELD_SRC;
script.async = true;
document.body.appendChild(script);
}, []);
return (
<main className="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback" />
</main>
);
}// shield-auth.d.ts — lets TypeScript know about the <shield-auth> element
import type { DetailedHTMLProps, HTMLAttributes } from "react";
type ShieldAuthProps = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
theme?: "light" | "dark";
redirect_uri: string;
api_url?: string;
};
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"shield-auth": ShieldAuthProps;
}
}
}Works with React 18 and 19 — every attribute is a plain string.
In the App Router, a Server Component can render the element directly. next/script loads the widget after hydration and never loads it twice.
import Script from "next/script";
export default function LoginPage() {
return (
<main className="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback" />
<Script src="https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js" strategy="afterInteractive" />
</main>
);
}// shield-auth.d.ts — lets TypeScript know about the <shield-auth> element
import type { DetailedHTMLProps, HTMLAttributes } from "react";
type ShieldAuthProps = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
theme?: "light" | "dark";
redirect_uri: string;
api_url?: string;
};
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"shield-auth": ShieldAuthProps;
}
}
}Put the type file at the project root so the default tsconfig picks it up.
Tell Vue's compiler that <shield-auth> is a native custom element, then load the script when the view mounts.
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Hand <shield-auth> to the browser instead of resolving a Vue component.
isCustomElement: (tag) => tag === "shield-auth",
},
},
}),
],
});<script setup lang="ts">
import { onMounted } from "vue";
const SHIELD_SRC = "https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js";
onMounted(() => {
if (document.querySelector(`script[src="${SHIELD_SRC}"]`)) return;
const script = document.createElement("script");
script.src = SHIELD_SRC;
script.async = true;
document.body.appendChild(script);
});
</script>
<template>
<main class="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback"></shield-auth>
</main>
</template>Same idea as Vue: register the custom element in nuxt.config, then load the script from onMounted so it only runs in the browser.
export default defineNuxtConfig({
vue: {
compilerOptions: {
isCustomElement: (tag) => tag === "shield-auth",
},
},
});<script setup lang="ts">
const SHIELD_SRC = "https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js";
onMounted(() => {
if (document.querySelector(`script[src="${SHIELD_SRC}"]`)) return;
const script = document.createElement("script");
script.src = SHIELD_SRC;
script.async = true;
document.body.appendChild(script);
});
</script>
<template>
<main class="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback"></shield-auth>
</main>
</template>CUSTOM_ELEMENTS_SCHEMA lets the template use <shield-auth>. afterNextRender runs only in the browser, so this is safe with Angular SSR.
import { Component, CUSTOM_ELEMENTS_SCHEMA, afterNextRender } from "@angular/core";
const SHIELD_SRC = "https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js";
@Component({
selector: "app-sign-in",
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<main class="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback"></shield-auth>
</main>
`,
})
export class SignInComponent {
constructor() {
afterNextRender(() => {
if (document.querySelector(`script[src="${SHIELD_SRC}"]`)) return;
const script = document.createElement("script");
script.src = SHIELD_SRC;
script.async = true;
document.body.appendChild(script);
});
}
}Svelte understands custom elements out of the box — just load the script on mount. Works in SvelteKit and plain Svelte.
<script lang="ts">
import { onMount } from "svelte";
const SHIELD_SRC = "https://aathorize.com/embed/shield/YOUR_PUBLIC_KEY.js";
onMount(() => {
if (document.querySelector(`script[src="${SHIELD_SRC}"]`)) return;
const script = document.createElement("script");
script.src = SHIELD_SRC;
script.async = true;
document.body.appendChild(script);
});
</script>
<main class="sign-in">
<shield-auth theme="light" redirect_uri="https://yourapp.com/auth/callback"></shield-auth>
</main>| Attribute | Value | What it does |
|---|---|---|
theme | light · dark | Colours of the card. Defaults to light. |
redirect_uri | Required · full URL | Where the visitor goes after signing in. Its origin must be on your URLs list. |
api_url | Optional | Points the widget at another API, such as a local backend. Leave it out in production. |
The moment the element appears, the widget calls POST /api/details with your
public key. Aathorize checks that the key is active and that redirect_uri is on your
allowed list, then returns your branding, the methods you enabled, and a fresh signature — a ticket for this one sign-in attempt. You never call this endpoint yourself.
POST https://api.aathorize.com/v1/api/details
x-api-key: YOUR_PUBLIC_KEY
Content-Type: application/json
{ "redirect_uri": "https://yourapp.com/auth/callback" }{
"success": true,
"signature": "cm1x7k2p40000abcd1234efgh",
"data": {
"name": "Your app",
"icon_url": "https://yourapp.com/logo.png",
"providers": [
{ "slug": "social_google", "name": "Google" },
{ "slug": "otp_email", "name": "Email" }
]
}
}Chapter 2 · Proving who they are
Your visitor picks a way in
Maybe they tap Continue with Google. Maybe they'd rather get a code by email, or scan a WhatsApp QR code with their phone. Whichever they choose, Shield runs the whole conversation — the OAuth redirects, sending and checking codes, waiting for the WhatsApp message — and records the sign-in in your analytics.
| Method | What the widget calls |
|---|---|
| Google, Facebook, LinkedIn | GET /api/auth/social/{signature}/{provider} Then the provider's consent screen, then back through Aathorize. |
| Email code | POST /api/auth/{signature}/email POST /api/auth/{signature}/email/verify The first sends a 5-digit code; the second checks what the visitor typed. |
GET /api/auth/{signature}/whatsapp GET /api/auth/{signature}/whatsapp/verify The first shows a QR code; the widget polls the second every second until the message arrives. |
Chapter 3 · The trip home
Shield sends them back to your callback
Signed in, your visitor is sent back to the redirect_uri you chose. Two query parameters
ride along: whether it worked, and an access token that stands for this person.
https://yourapp.com/auth/callback?success=true&access_token=oat_MTI.cWx2ZnN0dXZ3eHl6YWJjZGVmZ2ghttps://yourapp.com/auth/callback?success=false| Parameter | Meaning |
|---|---|
success | true or false. false means the visitor cancelled or the
provider refused. |
access_token | Only present when success=true. A bearer token (it starts with oat_) that
Aathorize can resolve to this visitor. |
?success=true into an address bar. The token only means something once your
server has checked it with Aathorize — that's the next chapter./auth/callback). It reads both parameters
and, when success isn't true, sends the visitor back to sign in with a friendly
message. The server examples in chapter 4 include this.Chapter 4 · The question
Your server asks Aathorize: who is this?
Your server is holding a token and needs a name to put to it. So it asks Aathorize the only question this integration requires — who does this token belong to? — and proves it's really you by sending your API secret alongside.
| Header | Value | Where it comes from |
|---|---|---|
Authorization | Bearer <access_token> | The callback URL, chapter 3 |
x-api-secret | <your API secret> | API keys, shown once when you create a key |
Try the call by hand first. Copy access_token from the address bar after a test sign-in.
curl "https://api.aathorize.com/v1/api/auth/user" \
-H "Authorization: Bearer oat_PASTE_ACCESS_TOKEN" \
-H "x-api-secret: $AATHORIZE_API_SECRET"An Express route at your redirect_uri. Node 18+ ships fetch, so there is nothing extra to install for the API call.
// npm i express express-session
import express from "express";
import session from "express-session";
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
// The redirect_uri you gave the widget points here.
app.get("/auth/callback", async (req, res) => {
const { success, access_token } = req.query;
if (success !== "true" || !access_token) {
return res.redirect("/login?error=cancelled");
}
// Ask Aathorize who this is. The secret never leaves your server.
const response = await fetch("https://api.aathorize.com/v1/api/auth/user", {
headers: {
Authorization: `Bearer ${access_token}`,
"x-api-secret": process.env.AATHORIZE_API_SECRET,
},
});
const data = await response.json();
if (!data.success) {
return res.redirect("/login?error=unauthorized");
}
// data.user → { id, name, identities: [{ type, value }] }
req.session.regenerate((err) => {
if (err) return res.redirect("/login?error=session");
req.session.user = data.user;
res.redirect("/dashboard"); // a clean URL: the token leaves the address bar
});
});
app.listen(3000);A Route Handler at your redirect_uri. It runs on the server, so process.env secrets are safe here.
import { NextResponse, type NextRequest } from "next/server";
import { createSession } from "@/lib/session"; // your session helper
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const accessToken = params.get("access_token");
if (params.get("success") !== "true" || !accessToken) {
return NextResponse.redirect(new URL("/login?error=cancelled", request.url));
}
const res = await fetch("https://api.aathorize.com/v1/api/auth/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
"x-api-secret": process.env.AATHORIZE_API_SECRET!,
},
cache: "no-store",
});
const data = await res.json();
if (!data.success) {
return NextResponse.redirect(new URL("/login?error=unauthorized", request.url));
}
// Save data.user and set an HttpOnly cookie —
// iron-session, Auth.js or your own sessions table all work.
await createSession(data.user);
return NextResponse.redirect(new URL("/dashboard", request.url));
}A +server.ts endpoint at your redirect_uri. $env/static/private keeps the secret out of the browser bundle.
import { redirect } from "@sveltejs/kit";
import { AATHORIZE_API_SECRET } from "$env/static/private";
import { createSession } from "$lib/server/session"; // your session helper
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, fetch, cookies }) => {
const accessToken = url.searchParams.get("access_token");
if (url.searchParams.get("success") !== "true" || !accessToken) {
redirect(303, "/login?error=cancelled");
}
const res = await fetch("https://api.aathorize.com/v1/api/auth/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
"x-api-secret": AATHORIZE_API_SECRET,
},
});
const data = await res.json();
if (!data.success) {
redirect(303, "/login?error=unauthorized");
}
// Save data.user and get back a random session id.
const sessionId = await createSession(data.user);
cookies.set("session", sessionId, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
redirect(303, "/dashboard");
};A web route at your redirect_uri. Laravel's HTTP client and Auth::login do the heavy lifting.
<?php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
// config/services.php → 'aathorize' => ['secret' => env('AATHORIZE_API_SECRET')]
Route::get('/auth/callback', function (Request $request) {
$token = $request->query('access_token');
if ($request->query('success') !== 'true' || ! $token) {
return redirect('/login')->with('error', 'Sign-in was cancelled.');
}
$response = Http::withToken($token)
->withHeaders(['x-api-secret' => config('services.aathorize.secret')])
->get('https://api.aathorize.com/v1/api/auth/user');
if (! $response->json('success')) {
return redirect('/login')->with('error', 'We could not verify your sign-in.');
}
$profile = $response->json('user');
$email = collect($profile['identities'])->firstWhere('type', 'email')['value'] ?? null;
// Link by Aathorize's stable id (add an aathorize_id column to users).
$user = User::updateOrCreate(
['aathorize_id' => $profile['id']],
['name' => $profile['name'], 'email' => $email],
);
Auth::login($user, remember: true);
$request->session()->regenerate();
return redirect()->intended('/dashboard');
});An async route at your redirect_uri, using httpx for the call and Starlette's signed-cookie sessions.
# pip install fastapi httpx itsdangerous
import os
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
@app.get("/auth/callback")
async def auth_callback(request: Request, success: str = "false", access_token: str | None = None):
if success != "true" or not access_token:
return RedirectResponse("/login?error=cancelled", status_code=303)
async with httpx.AsyncClient() as client:
res = await client.get(
"https://api.aathorize.com/v1/api/auth/user",
headers={
"Authorization": f"Bearer {access_token}",
"x-api-secret": os.environ["AATHORIZE_API_SECRET"],
},
)
data = res.json()
if not data.get("success"):
return RedirectResponse("/login?error=unauthorized", status_code=303)
request.session["user_id"] = data["user"]["id"]
request.session["name"] = data["user"]["name"]
return RedirectResponse("/dashboard", status_code=303)A net/http handler at your redirect_uri. Only the standard library is needed for the call.
package main
import (
"encoding/json"
"net/http"
"os"
)
type AathorizeUser struct {
ID string `json:"id"`
Name string `json:"name"`
Identities []struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"identities"`
}
func authCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
token := q.Get("access_token")
if q.Get("success") != "true" || token == "" {
http.Redirect(w, r, "/login?error=cancelled", http.StatusSeeOther)
return
}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.aathorize.com/v1/api/auth/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("x-api-secret", os.Getenv("AATHORIZE_API_SECRET"))
res, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "Aathorize is unreachable", http.StatusBadGateway)
return
}
defer res.Body.Close()
var body struct {
Success bool `json:"success"`
User AathorizeUser `json:"user"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil || !body.Success {
http.Redirect(w, r, "/login?error=unauthorized", http.StatusSeeOther)
return
}
startSession(w, body.User) // yours: store the user, set an HttpOnly cookie
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// In main: http.HandleFunc("/auth/callback", authCallback)What comes back
{
"success": true,
"message": "User details retrieved successfully",
"user": {
"id": "user_cm1x8q0z70001wxyz5678ijkl",
"name": "Asha Rao",
"identities": [
{ "type": "email", "value": "asha@example.com" }
]
}
}// 401 — the access token is missing, malformed or unknown
{ "success": false, "error": "Unauthorized Access" }
// 200 — the token is fine but the API secret is wrong or revoked
{ "success": false, "message": "Unauthorized access" }Always check success in the body — a wrong secret still answers with HTTP 200.
data.user once and sign in as yourself. You should see your own name and email.Chapter 5 · Opening the door
Give your visitor a session
Aathorize has vouched for your visitor. Everything from here belongs to your app: find or create their account, start a session, and send them somewhere useful — on a clean URL, so the token leaves the address bar for good.
| Field | What it holds |
|---|---|
user.id | Stable for this person in this project, e.g. user_cm1x…. Link accounts on this. |
user.name | Display name from the provider, or the part of the email before the @. |
user.identities | A list of { type, value }, where type is email or phone.
Email and social sign-ins bring an email; WhatsApp brings a phone number. |
type AathorizeUser = {
id: string;
name: string;
identities: { type: "email" | "phone"; value: string }[];
};
export async function findOrCreateAccount(user: AathorizeUser) {
const email = user.identities.find((i) => i.type === "email")?.value ?? null;
const phone = user.identities.find((i) => i.type === "phone")?.value ?? null;
// user.id is stable for this project — link on it, not on email.
return db.account.upsert({
where: { aathorizeId: user.id },
create: { aathorizeId: user.id, name: user.name, email, phone },
update: { name: user.name },
});
}- Link on
user.id, not on email — someone who signs in with WhatsApp may have no email at all. - Keep the session on your server: an HttpOnly cookie holding a random id, or a signed session.
- Redirect after the exchange so the token doesn't linger in browser history, logs or Referer headers.
The end — and the beginning.
That's the whole story: one element, one script, one API call. Your visitor is in, and you never stored a password.
Go-live checklist
- Your production origin is on the URLs list. Once the list has one entry, every other origin is rejected.
- AATHORIZE_API_SECRET lives only in server environment variables — not in frontend code, not in git.
- Your redirect_uri uses HTTPS.
- Your callback handles success=false with a clear way back to sign in.
- You redirect to a clean URL right after the exchange, so the token leaves the address bar.
- Accounts are linked on user.id, not on email.
- You signed in once with every enabled method, on the production domain.
- To rotate keys: create a new key, deploy it, then revoke the old one.
API reference
Base URL: https://api.aathorize.com/v1. Only the last row is yours to call; the rest are listed for debugging.
| Endpoint | Called by | Auth | Purpose |
|---|---|---|---|
| GET https://aathorize.com/embed/shield/{public_key}.js | Browser | Public key in the URL | Loads the widget |
| POST /api/details | Widget | x-api-key | Branding, sign-in methods and a signature |
| GET /api/auth/social/{signature}/{provider} | Widget | Signature | Starts Google, Facebook or LinkedIn |
| POST /api/auth/{signature}/email | Widget | Signature | Emails a 5-digit code |
| POST /api/auth/{signature}/email/verify | Widget | Signature | Checks the code, returns the redirect |
| GET /api/auth/{signature}/whatsapp | Widget | Signature | Shows the WhatsApp QR code |
| GET /api/auth/{signature}/whatsapp/verify | Widget | Signature | Polls until WhatsApp approves |
| GET /api/auth/user | Your server | Bearer token + x-api-secret | Tells you who signed in |
Troubleshooting
details request and read its message. Origin not allowed means the origin of redirect_uri isn't on your URLs list. Error fetching project details means the public key is wrong, disabled or revoked, or the
project is inactive.Authorization: Bearer <token>. A 200 with success: false means the secret is wrong, revoked, or belongs to a different project.shield-auth.d.ts file from the React or Next.js tab in chapter 1.isCustomElement to your Vite or Nuxt config, as shown in the Vue and Nuxt tabs.CUSTOM_ELEMENTS_SCHEMA to the component's schemas.