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
- Settings → Actions → + Create action.
- Trigger:
pre_token_mint. - Endpoint URL: the public HTTPS URL of your webhook (Lambda function URL, Cloud Run, your existing API).
- Fail mode:
open(recommended for role overrides — failing closed means a transient outage in your roles DB takes down every sign-in). - Timeout: leave the default 2000 ms unless your DB query is unusually slow.
- Click Create action. Copy the signing secret (prefix
asec_) from the modal that appears — you will not see it again. - Paste the secret into your webhook’s
AUTHIO_ACTION_SECRETenv var and redeploy. - 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
rolestable are silently dropped and audit-logged. Mirror your role slugs into Authio (create them withPOST /v1/session/roles) so the names match. - fail_mode. Use
openfor 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.
