AI Agent Access Control: Secrets, Sign-In, Roles and Network Limits

A service that anyone can call is a service that anyone can misuse. AI agent access control decides who can do what, and it must be enforced by your code. In this lesson you will replace the temporary local security with real tokens and roles, then test that the limits actually hold.

Advertisement

📖 What You’ll Learn

  • How to store API tokens safely
  • How roles limit what people can do
  • How to manage secrets in production
  • What network limits to apply

💡 Three Layers of AI Agent Access Control

Explain Like I’m 5: A Building with Security

Imagine: A guard checks your badge at the door. Your badge opens only certain rooms. The building also has a fence around it.

What it really means: Sign-in, roles and network limits work together.

In testing: A valid token still cannot reach a role it does not have.

LayerQuestionOur answer
AuthenticationWho are you?A bearer token
AuthorisationWhat may you do?Roles and permissions
NetworkCan you reach it at all?VPN and firewall rules

🔍 Roles and Permissions

The roles are in src/service/security.ts, which you created in AI Agent as a Service.

RoleCan read runsCan start runsCan approveCan operate
ViewerYesNoNoNo
RequesterYesYesNoNo
ApproverYesNoYesNo
AdminYesYesYesYes

Give people the lowest role that lets them do their job. Keep admin rare.


🧩 Store Tokens Safely

Explain Like I’m 5: The Coat Check

Imagine: You get a numbered ticket for your coat. The ticket alone proves that the coat is yours, so the cloakroom only needs a matching number.

What it really means: The service stores a fingerprint and never the token itself.

In testing: A stolen database does not reveal usable tokens.

Create src/service/token-security.ts.

import { createHash, randomBytes } from 'node:crypto';
import type { Request } from 'express';
import type { Actor, Role, Security } from './security.js';
import type { ServiceStore } from './store.js';

export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');

export function issueToken(store: ServiceStore, name: string, role: Role): string {
  const token = `qat_${randomBytes(32).toString('base64url')}`;
  store.addToken(name, role, hashToken(token));
  return token;
}

export class TokenSecurity implements Security {
  constructor(private readonly store: ServiceStore) {}

  authenticate(request: Request): Actor | null {
    const header = request.header('authorization') ?? '';
    const match = header.match(/^Bearer (\S+)$/);
    if (!match) {
      return null;
    }
    const token = this.store.findToken(hashToken(match[1]));
    if (!token || token.revoked_at !== null) {
      return null;
    }
    return { id: token.name, role: token.role as Role };
  }
}

Long random tokens are safe to fingerprint with SHA-256. Passwords are different, and they need a slow password-hashing function.

Create scripts/create-token.ts.

import { loadServiceConfig } from '../src/service/config.js';
import type { Role } from '../src/service/security.js';
import { ServiceStore } from '../src/service/store.js';
import { issueToken } from '../src/service/token-security.js';

const [name, role] = process.argv.slice(2);
if (!name || !['viewer', 'requester', 'approver', 'admin'].includes(role)) {
  console.error('Usage: npm run create-token -- <name> <viewer|requester|approver|admin>');
  process.exit(1);
}

const store = new ServiceStore(loadServiceConfig().DATABASE_PATH);
console.log(issueToken(store, name, role as Role));
console.error('Save this token now. Only its hash is stored, so it cannot be shown again.');
store.close();

Add the script and create one token per person, and not one shared by everyone.

npm pkg set scripts.create-token="tsx scripts/create-token.ts"
npm run create-token -- asha requester
npm run create-token -- ben approver

Now use it. In src/service/main.ts, import TokenSecurity and replace the security option.

security: config.AUTH_MODE === 'token' ? new TokenSecurity(store) : new LocalDevSecurity(),

🛠️ Test the Limits

Explain Like I’m 5: Trying the Locked Door

Imagine: After locking up, you push the door to be sure that it really is locked.

What it really means: Prove that each forbidden action is refused.

In testing: A viewer who tries to start a run must get a 403.

Create tests/unit/security.test.ts. It starts the real API.

import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { after, before, test } from 'node:test';
import { createApi } from '../../src/service/api.js';
import { ServiceStore } from '../../src/service/store.js';
import { issueToken, TokenSecurity } from '../../src/service/token-security.js';

const store = new ServiceStore(':memory:');
const viewer = issueToken(store, 'vic', 'viewer');
const requester = issueToken(store, 'asha', 'requester');
const approver = issueToken(store, 'ben', 'approver');
let server: Server;
let url: string;

before(async () => {
  const app = createApi({ root: '.', store, security: new TokenSecurity(store), environments: ['demo'] });
  await new Promise<void>((done) => {
    server = app.listen(0, '127.0.0.1', () => done());
  });
  url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
after(() => server.close());

const post = (path: string, token: string | null, body: unknown) =>
  fetch(url + path, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify(body),
  });
const run = { requirement: 'Login', environment: 'demo' };

test('requests without a valid token are refused', async () => {
  assert.equal((await post('/v1/runs', null, run)).status, 401);
  assert.equal((await post('/v1/runs', 'qat_wrong', run)).status, 401);
});

test('roles limit what each person can do', async () => {
  assert.equal((await post('/v1/runs', viewer, run)).status, 403);
  assert.equal((await post('/v1/runs', approver, run)).status, 403);
  assert.equal((await post('/v1/runs', requester, run)).status, 202);
});

test('only approved environments are accepted', async () => {
  const response = await post('/v1/runs', requester, { requirement: 'Login', environment: 'production' });
  assert.equal(response.status, 400);
});

test('bad input gets a clean error', async () => {
  const response = await fetch(`${url}/v1/runs`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${requester}` },
    body: '{broken',
  });
  assert.equal(response.status, 400);
});

test('only the hash of a token is stored, and it can be revoked', async () => {
  assert.equal(store.findToken(requester), undefined);
  const temporary = issueToken(store, 'temp', 'requester');
  store.revokeToken('temp');
  assert.equal((await post('/v1/runs', temporary, run)).status, 401);
});

Test what must fail

A security feature is proven by what it refuses. Your access tests should mostly be about the people and actions that must not succeed.


📌 Handle Secrets

SecretWhere it belongs
Model API keySecrets manager or protected server file
Slack signing secretSecrets manager or protected server file
Test account passwordSecrets manager, and synthetic only
API tokensOnly hashes stored, and shown once

Rotate secrets on a schedule, and immediately after any suspected leak. Use a separate key for each environment. The MCP Security for Testers lesson covers the same principles.


🔑 Limit the Network

  • Allow inbound traffic only from your VPN or proxy.
  • Allow outbound traffic only to your test environment and model provider.
  • Block access to cloud metadata addresses, such as 169.254.169.254.
  • Keep your database and artifact storage private.

Your application has its own allowlist too. Requests can name only configured environments, and the explorer checks every host.


💡 When to Add Single Sign-On

Tokens are enough to start. As more people use the service, connect it to your company’s identity provider, using OpenID Connect, so that leavers lose access automatically. Your Security interface means that only one class has to change.


🌍 Real-Life Example: The Shared Token

A team shares one admin token in a chat. Months later, someone approves a risky run, and the audit log says only “admin”. Nobody can tell who it was. One token per person, with the lowest role needed, makes every action traceable.


🧪 Try It Yourself

  1. Create the files, and run the unit tests.
  2. Create a token for each of the four roles.
  3. Try each API route with each token.
  4. Revoke a token, and confirm that it stops working.
  5. Search your logs for any token value.

📋 Ready-to-Copy Prompt

You are an application security engineer reviewing access control for a QA
agent service.

Routes and permissions:
[paste them with no secrets]

Find any route that is missing a check, any role that has too much power
and anything that could let one person both request and approve an action.

Write a negative test case for each finding.

⚠️ Common Mistakes

  • Sharing one token across a team
  • Storing tokens in plain text
  • Giving everyone the admin role
  • Testing only the allowed cases
  • Letting the agent’s server reach anything on the internet

🎯 Key Takeaways

  • Authentication, roles and network limits work together.
  • Store only hashes of long random tokens.
  • Give each person the lowest role they need.
  • Prove your security with negative tests.

🎤 Interview Questions

1. What is the difference between authentication and authorisation?

Model answer: Authentication confirms who someone is. Authorisation decides what they are allowed to do.

2. How would you store API tokens?

Model answer: I would generate long random values, show them once and store only a hash. Tokens should be individual and revocable.

3. How would you test role-based access?

Model answer: I would test every role against every route, focusing on actions that must be refused. I would also test missing, invalid and revoked tokens.

4. Why block cloud metadata addresses?

Model answer: They can expose instance credentials. If an agent or test can reach them, a mistake could leak access to the cloud account.

5. Why does every action need an individual identity?

Model answer: It makes the audit trail meaningful, and it allows one person’s access to be removed without affecting others.


📝 Assignment

Create a matrix of every route against every role, with the expected status code. Automate it as a single data-driven test, and include a revoked token.


❓ FAQ

Can I use this token approach permanently?

For a small internal tool, it can work well. Larger organisations usually prefer single sign-on.

Why SHA-256 for tokens but not for passwords?

Long random tokens cannot be guessed. Passwords can be, so they need a slow function such as Argon2.


🚀 Next Steps

Well done on finishing this lesson. Complete the assignment, then continue with Human Approval Workflow for AI Agents: Slack, Jira and Pull Requests.