← All posts
Security9 min readProject: Central auth service

Server-to-server tokens with exact permissions: designing service tokens

When one backend calls another, there's no user to log in. Shared secrets give too much access and are impossible to rotate cleanly. Here is how I designed service tokens: one app, an explicit permission list, instant revocation through Redis, reissue that kills the old token at the same moment, and an encrypted copy admins can retrieve later.

#Authentication#Microservices#JWT#Redis#API Security#Machine-to-Machine

The problem

Services calling each other had no proper identity: shared secrets gave all-or-nothing access and couldn't be revoked or scoped.

The result

Each calling service gets a token scoped to one app with an explicit permission list, revocable instantly, reissued safely, and auditable from the admin panel.

Stack

NestJSJWTRedisPrismaPostgreSQLNode.js crypto

The problem

Our apps don't only talk to people. They talk to each other:

  • one service tells another "this data changed, refresh what depends on it",
  • other services download exports from an app,
  • scheduled jobs call internal endpoints at night.

None of those calls has a user behind it. There's no login, no session, no cookie. So how does the receiving app know the caller is allowed?

The usual shortcuts all have problems:

  • No auth on internal routes ("it's inside the network"): anything inside the network can call them, including a compromised container.
  • One shared secret in every service: whoever has it can call everything. You can't tell which service called, and rotating it means redeploying every service at once.
  • A fake "system" user with a group: it mixes machine access with human access management, and a human admin reorganizing groups can accidentally break a nightly job.

We needed something designed for machines: an identity per calling service, permissions as precise as a person's, and revocation as instant as a logout.

The details

What a service token is

A service token represents a calling service, not a person. It is:

  • scoped to exactly one app: a token for calling the configurator can't be used against billing,
  • given an explicit list of permissions, chosen from that app's catalog, assigned directly, with no group in between,
  • time-limited, with its own duration,
  • revocable instantly, like a user session.

The model:

model ServiceToken {
  id              Int      @id @default(autoincrement())
  appId           Int
  name            String              // e.g. "nightly-sync"
  token           String              // encrypted copy of the current bearer value
  durationSeconds Int      @default(3600)
  expiresAt       DateTime
  status          ServiceTokenStatus @default(ACTIVE)
  permissions     ServiceTokenPermission[]

  @@unique([appId, name])
}

Why no groups

Human access goes through groups (see "Groups by department"). For machines, that indirection is a liability: a nightly job's rights would depend on how humans organize departments. A service token's permission list is explicit and local to the token. You read it, and you know exactly what the service can do.

The challenges

JWTs can't be edited

A JWT is signed. If you change a token's permissions, you can't modify the existing token; you have to issue a new one. And then the old one must stop working immediately, or the "removed" permission still works until expiry.

Show it once, or keep it?

The common practice for secrets is "show it once, never again". But in an internal platform, admins regularly need to re-configure a service (a new server, a lost environment file). If the token can only be seen once, every such case means generating a new token and redeploying.

Telling machine tokens from user tokens

Both are JWTs signed by the same auth service. Apps must distinguish them before running user-specific logic (sessions, groups, Root Admin shortcuts).

No admin bypass

Users can be Root Admins, who skip permission checks. A machine must never get that kind of shortcut.

The solution

1. Creating a token: validate against the real catalog

async create(dto: { appId: number; name: string; permissionKeys: string[]; expiresInSeconds: number }) {
  const app = await apps.findById(dto.appId);
  if (!app) throw new NotFoundException(`Application ${dto.appId} not found.`);
  if (app.status !== 'ACTIVE') {
    throw new ConflictException(`Application "${app.name}" is disabled. Restore it before minting a token for it.`);
  }
  if (await tokens.findByAppAndName(dto.appId, dto.name)) {
    throw new ConflictException(`A service token named "${dto.name}" already exists for this application.`);
  }

  // Every requested permission must exist in this app's catalog
  const catalog = await catalogs.findByApp(dto.appId);
  const idByKey = new Map(catalog.map((p) => [p.permissionKey, p.id]));
  const unknown = dto.permissionKeys.filter((k) => !idByKey.has(k));
  if (unknown.length) throw new BadRequestException(`Unknown permission key(s) for this application: ${unknown.join(', ')}`);

  // …create the row, cache permissions in Redis, mint (below)
}
  • A token can only carry permissions that really exist in the target app's catalog (which the app itself publishes; see "Permissions the apps declare themselves"). Typos fail at creation, not at 3 a.m.
  • Names are unique per app, and stay reserved even after revocation, so a log line saying "nightly-sync" always means the same token.

There's a small chicken-and-egg problem: the JWT embeds the token's database id, which only exists after the row is created. So the row is created with an empty placeholder, the JWT is signed, and the row is updated with the real (encrypted) value in the same request. No caller ever sees the placeholder.

2. Minting: always bump first

Every time a token is issued (at creation, on refresh, or after a permission change), the mint helper bumps the token's version first:

async mint({ serviceTokenId, appId, app, durationSeconds, permissionsVersion }) {
  // A "fresh" token is always a NEW token: bump first, so whatever was
  // issued before dies at the same moment.
  const tokenVersion = await serviceVersions.bumpTokenVersion(serviceTokenId);

  const payload = { type: 'service', serviceTokenId, appId, app, tokenVersion, permissionsVersion };
  const token = SERVICE_TOKEN_PREFIX + jwt.sign(payload, { expiresIn: durationSeconds });

  await tokens.setToken(serviceTokenId, encrypt(token)); // keep a retrievable copy
  return { token, expiresInSeconds: durationSeconds };
}

Because every app compares the token's version with Redis on every call, bumping the version revokes every previously issued token for that service, instantly. There's never a window where both the old and new token work.

The duration is remembered on the row, so a refresh or a permission edit reissues a token with the same lifetime it was created with, without asking again.

3. Changing permissions = reissuing

Adding or removing a permission:

  1. updates the token's permission list in the database,
  2. updates the cached permission list in Redis,
  3. reissues the token (which bumps the version, which kills the old one).

The admin gets the new token back. The old token stops working on its next call.

4. Revocation and status

Disabling a token sets its status to DISABLED. Introspection and app checks refuse it with "This token has been revoked." Hard delete removes it entirely. As with apps and users, the reversible step comes first.

5. Recognizable before parsing

Service tokens carry a visible prefix, and the JWT payload has type: 'service'. Apps check the prefix before parsing anything:

const bearer = extractBearerToken(request);
if (bearer?.startsWith(SERVICE_TOKEN_PREFIX)) {
  return this.canActivateServiceToken(context, request, bearer);
}
// …otherwise: the normal user flow, completely untouched

The user flow (sessions, groups, Root Admin) never runs for a machine token, and the service-token flow never touches user logic. Adding service tokens didn't change a single line of the existing, production user-token code: they even have their own version service, keyed by token id instead of user id.

6. Verification: signature, live version, live permissions

When a service token reaches an app:

  1. Prefix check, then signature and expiry check.
  2. type must be 'service'.
  3. Live tokenVersion from Redis must match: revoked tokens fail immediately.
  4. The required permission must be in the token's list. No admin bypass, ever: a machine's permission list is always explicit.

There's also an introspection endpoint on the auth service for apps that want more detail. It checks that the token was issued for the calling app (a token for app A presented to app B is refused), that it still exists and is active, and returns its current permissions read live from Redis, not from the JWT. So a permission change is visible to introspection even before the caller picks up its reissued token.

7. An encrypted copy for admins

The current token value is stored encrypted (AES-256-GCM, the same helper used for app API keys). Admins can retrieve it from the admin panel when a service needs to be reconfigured. Since every reissue overwrites the stored value, there's only ever one valid copy, and it's always the current one.

This is a deliberate trade-off. "Show once" is stricter, but in an internal platform where admins legitimately need to reconfigure services, a single encrypted, always-current copy behind admin access was the more practical and still controlled choice.

8. What it looks like in practice

Admin panel → Service tokens → New
  App:          inventory
  Name:         nightly-export
  Permissions:  inventory:exports:read
  Duration:     30 days

→ token issued, stored encrypted, permissions cached in Redis
→ the calling service puts it in its environment and sends it as
  "Authorization: Bearer <token>"
→ the inventory app checks prefix, signature, live version, and that
  inventory:exports:read is in the list

If that calling service is ever compromised, revoking its token is enough. It could only ever call one endpoint in one app, and nothing else in the company is affected.

Lessons learned

  • Machines deserve their own identities. One token per calling service makes access precise and revocation targeted.
  • Least privilege, explicitly. A list of exact permissions per token beats "trusted internal network" every time.
  • Keep machines out of human access management. No groups and no admin bypass: a reorganization should never break a nightly job.
  • You can't edit a JWT, so reissue and revoke together. Bump the version before minting, so old tokens die the moment new ones exist.
  • Validate against the real catalog. Permissions that don't exist should fail at creation, not in production.
  • Tell token types apart before parsing. A prefix keeps the user flow and the machine flow from ever mixing.
  • Read permissions live when it matters. Introspection from Redis reflects changes immediately.
  • Make storage a conscious trade-off. If you keep a retrievable copy, encrypt it and keep only the current one.

FAQ

What is a service token?

A credential for machine-to-machine calls. It identifies a calling service rather than a person, is scoped to a target application, carries an explicit set of permissions and can be revoked independently of user accounts.

How do I revoke a JWT used between services?

Embed a version number in the token and store the current version in a shared store like Redis. Increment it to revoke, and have the receiving service compare versions on every call.

Should service tokens bypass permission checks?

No. Service tokens should always carry an explicit permission list and be checked like any other caller. Admin-style shortcuts for machines turn a single leaked token into full access.

Is it safe to store API tokens so admins can view them again?

It's a trade-off. If you do, encrypt them with authenticated encryption, restrict who can read them, and keep only the current value so old copies don't linger. If you don't need it, showing a token only once is stricter.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us