Authio docs

Actions

Pattern 3 — your roles table, our JWT

Keep your existing roles table as the source of truth. Authio asks your Action on every JWT mint and copies your answer into the access-token claims.

Pattern 3 is for customers whose roles live in their own database (often a long-standing users + memberships + roles schema) and who don’t want to mirror that catalogue into Authio. Instead of SCIM-pushing role changes to Authio, you sit in the JWT-mint hot path and answer "what are this user’s roles right now?"

See Customer roles · source of truth for the trade-offs against Pattern 1 (Authio-as-source) and Pattern 2 (IdP-as-source).

Step 1 · Turn on the project toggle

In the dashboard, go to Settings → Authorization and flip roles_action_override on. Without this toggle, any override_roles field on a verdict is silently dropped (and an audit-log entry surfaces the mis-config).

Step 2 · Write the webhook

Your endpoint signs in with the event’s user.id + user.email + session.organization_id and returns the role + permission slugs you want on the JWT. Example: a Node.js endpoint that queries Postgres via pg.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
import pg from "pg";

const SECRET = process.env.AUTHIO_ACTION_SECRET;
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

function verifyAuthioSignature(rawBody, header) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
  const expected = createHmac("sha256", SECRET)
    .update(t + ".")
    .update(rawBody)
    .digest("hex");
  return (
    expected.length === v1.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
  );
}

const app = express();
app.use(
  "/authio-action",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const raw = req.body.toString("utf8");
    if (!verifyAuthioSignature(raw, req.header("authio-signature"))) {
      return res.status(401).send({ code: "invalid_signature" });
    }
    const event = JSON.parse(raw);
    if (event.trigger !== "pre_token_mint") {
      return res.status(200).json({ decision: "allow" });
    }
    const userEmail = event.user?.email;
    const orgId = event.session?.organization_id;
    if (!userEmail || !orgId) {
      // No external identity yet — let Authio defaults apply.
      return res.status(200).json({ decision: "allow" });
    }

    // Look up the user's roles in YOUR DB.
    const { rows } = await pool.query(
      `
      SELECT r.slug AS role_slug,
             COALESCE(array_agg(DISTINCT p.slug)
                      FILTER (WHERE p.slug IS NOT NULL), '{}'::text[]) AS perms
      FROM users u
      JOIN organization_members om ON om.user_id = u.id
      JOIN roles r ON r.id = om.role_id
      LEFT JOIN role_permissions rp ON rp.role_id = r.id
      LEFT JOIN permissions p ON p.id = rp.permission_id
      WHERE lower(u.email) = lower($1)
        AND om.external_organization_id = $2
        AND om.status = 'active'
      GROUP BY r.slug
      `,
      [userEmail, orgId],
    );

    const roles = rows.map((r) => r.role_slug);
    const permissions = [...new Set(rows.flatMap((r) => r.perms))];

    return res.status(200).json({
      decision: "allow",
      override_roles: roles,
      override_permissions: permissions,
    });
  },
);

app.listen(3000);

Step 3 · Register the action in the dashboard

  1. Settings → Actions → + Create action.
  2. Trigger: pre_token_mint.
  3. Endpoint URL: the public HTTPS URL of your webhook (Lambda function URL, Cloud Run, your existing API).
  4. Fail mode: open (recommended for role overrides — failing closed means a transient outage in your roles DB takes down every sign-in).
  5. Timeout: leave the default 2000 ms unless your DB query is unusually slow.
  6. Click Create action. Copy the signing secret (prefix asec_) from the modal that appears — you will not see it again.
  7. Paste the secret into your webhook’s AUTHIO_ACTION_SECRET env var and redeploy.
  8. Click Send test event in the dashboard. The invocation row should land with status=ok.

Step 4 · A user signs in

Your user signs in via passkey / magic link / OAuth as usual. Right before Authio signs the access JWT, it POSTs:

POST /authio-action HTTP/1.1
Host: api.acme.com
Authio-Signature: t=1716660000,v1=4f1b3a...
Authio-Action-Id: action_01HX...
Authio-Trigger: pre_token_mint
Authio-Event-Id: evt_01HX...
Content-Type: application/json

{
  "event_id": "evt_01HX...",
  "trigger": "pre_token_mint",
  "occurred_at": "2026-05-25T15:00:00Z",
  "project": { "id": "proj_acme_prod" },
  "user": {
    "id": "user_01HX...",
    "email": "alice@acme.com",
    "email_verified": true
  },
  "session": {
    "id": "sess_01HX...",
    "organization_id": "org_acme_us"
  },
  "token": {
    "token_type": "user",
    "roles": ["member"],
    "permissions": [],
    "ttl_seconds": 900
  }
}

Your webhook responds:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "decision": "allow",
  "override_roles": ["billing_admin", "auditor"],
  "override_permissions": [
    "invoices:read",
    "invoices:approve",
    "audit-log:read"
  ]
}

The minted JWT carries:

{
  "iss": "https://auth.acme.com",
  "sub": "user_01HX...",
  "aud": ["acme-prod"],
  "exp": 1716660900,
  "iat": 1716660000,
  "sid": "sess_01HX...",
  "act_org": "org_acme_us",
  "roles": ["auditor", "billing_admin"],
  "permissions": [
    "audit-log:read",
    "invoices:approve",
    "invoices:read"
  ]
}

Authio’s roles table is bypassed entirely. The customer’s downstream services read roles / permissions from the JWT the same way they would for Pattern 1.

Note: Authio sorts and dedupes both slug arrays so the JWT shape is deterministic. Your DB query doesn’t need to ORDER BY.

Operational considerations

  • Latency. The whole sign-in is blocked on your webhook. Keep your role-lookup query under 200 ms p99. Cache aggressively (a 5-second per-user cache is fine for most role models).
  • Unknown slugs. Override slugs that don’t exist in the project’s Authio roles table are silently dropped and audit-logged. Mirror your role slugs into Authio (create them with POST /v1/session/roles) so the names match.
  • fail_mode. Use open for role overrides — a transient outage in your DB will let users keep signing in with default roles instead of locking everyone out.
  • Kill-switch. If your endpoint stays unhealthy for 5 minutes, Authio auto-disables the action and emails the project owners. See Kill-switch behavior.

Read next