Cite this as: Apier.no — Maskinporten Guide v1.0
Maskinporten for Developers
TL;DR.Maskinporten is Norway's machine-to-machine OAuth2 service. If you're building software that calls Altinn, Skatteetaten, or any other Norwegian government API on behalf of an organization, you need it. The setup is bureaucratic — a hardware certificate, a portal that signs you up to legal terms, a JWK upload, a JWT signing flow, and a separate System User layer for delegation. This guide walks the full path with working code, plus the ten pitfalls that quietly cost a day each.
Estimated read time: 14 minutes.
What is Maskinporten?
Maskinporten is the machine-to-machine OAuth2 service operated by Digitaliseringsdirektoratet (Digdir). It issues short-lived access tokens to authenticated machine clients. Norwegian government APIs — Altinn 3, Skatteetaten, NAV, Brønnøysund-derived services, and others — accept Maskinporten access tokens as the identity layer.
Maskinporten authenticates organizations, not humans. Don't confuse it with BankID (human identity for citizens) or ID-porten (human single-sign-on for public services). If your software acts on behalf of a person, those are different surfaces.
Quick glossary you'll see throughout:
- virksomhetssertifikat— your organization's digital certificate, issued by Buypass or Commfides, used to identify you to Norwegian government infrastructure.
- JWK — JSON Web Key. The public-key half of your signing keypair, in the JSON shape Maskinporten expects.
- kid — Key ID. A short string that lets Maskinporten select the right public key when you have several uploaded.
- scope— the permission you're asking for (e.g.
altinn:enterprise/v1.read). - aud / nbf / exp — JWT claims for audience, not-before, expiry. Maskinporten is strict about all three.
The big picture: how a request flows
Once you're set up, every government-API call follows the same six-step shape:
- Your service holds a private key paired with a JWK uploaded to Samarbeidsportalen.
- Your service builds a JWT assertion:
iss= your client id,aud= Maskinporten token endpoint,scope= requested scopes,jti= unique per request,iat/exp= issuance / expiry. - Your service signs the assertion with the private key (RS256).
- Your service POSTs the assertion to Maskinporten's
/tokenendpoint asgrant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. - Maskinporten verifies the signature against the uploaded JWK and returns an
access_token(typically ~120 seconds lifetime). - Your service uses the
access_tokenas a Bearer header on the downstream government API call.
your-service ─[1] sign JWT─▶ Maskinporten /token
◀─[2] access_token (~120s)──
your-service ─[3] Bearer access_token──▶ Altinn / Skatteetaten / NAVTokens are short-lived. Cache them in memory and refresh 30 seconds before expiry — re-fetching per request burns rate limit and adds latency to every call.
Step 1: Get a virksomhetssertifikat
Where: Buypass or Commfides. Both are CA providers Digdir trusts.
Why:the certificate identifies your organization — by org_number — to Norwegian government infrastructure. Maskinporten won't issue you a client without one.
Cost: typically several thousand NOK per year. Both providers publish current pricing on their sites.
Time: issuance often takes 1–2 weeks after order — the CA verifies your organization through formal paper or e-ID channels.
Pitfall: the certificate ships on a hardware token by default (a USB device). For server-side use you explicitly want a soft variant (P12 / PFX). Ask the CA up front; switching after the fact is a re-issuance cycle.
Step 2: Register in Samarbeidsportalen
Go to samarbeid.digdir.no. Sign in using your virksomhetssertifikat. Sign bruksvilkår (the terms of use) on behalf of your organization.
Create a Maskinporten client. Start in the test environment — the prod environment is a separate registration and a separate set of keys.
Recommended client settings:
token_endpoint_auth_method:private_key_jwtapplication_type:web- Token lifetime:
120seconds (the default — bigger values aren't accepted) client_name: descriptive (it appears in audit trails)
Pitfall:bruksvilkår signed on behalf of the wrong legal entity. The signing org_number must match the cert you signed in with. If you signed personally and your cert is in your employer's name, the registration is invalid.
Step 3: Generate a JWK and upload
Generate an RSA keypair locally — openssl is the path of least resistance:
openssl genrsa -out apier-test-private.pem 2048
openssl rsa -in apier-test-private.pem -pubout -out apier-test-public.pemThe public key needs to be in JWK form before upload. The shape Maskinporten expects:
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "your-key-id",
"n": "...",
"e": "AQAB"
}Upload via Samarbeidsportalen → your client → Keys → Add key.
Pitfall: forgetting alg: RS256 and use: sig. Maskinporten silently fails signature verification when these are wrong rather than emitting a clear "wrong key shape" error — you get the generic invalid_client instead and lose an hour to it.
Security note:store the private key in your secrets manager — Vercel project env, AWS Secrets Manager, Doppler, etc. Don't commit it. Don't print it to logs (Apier guard rail 2 — same applies to your code).
Step 4: Build and sign the JWT assertion
Node.js example using the jose library — actively maintained, no native dependencies, and works on edge runtimes. jsonwebtokenworks too if you're already using it; the claim shape is identical.
The required claims:
iss— your client id from Samarbeidsportalenaud— the Maskinporten token endpoint URL with trailing slash; exact-string match. Verify the current value at docs.digdir.no.scope— space-separated scope listiat— now (Unix seconds)exp—now + 60(Maskinporten rejects assertion lifetimes > 120s)jti— a fresh UUID per assertion (see code below —crypto.randomUUID(), notMath.random())
import { SignJWT, importPKCS8 } from "jose";
import { randomUUID } from "node:crypto";
// Test environment — verify the current URL on docs.digdir.no
// before shipping. The trailing slash is required; an exact-
// string match is performed.
const TEST_AUDIENCE = "https://test.maskinporten.no/";
const privateKey = await importPKCS8(
process.env.MASKINPORTEN_PRIVATE_KEY!,
"RS256",
);
const now = Math.floor(Date.now() / 1000);
const assertion = await new SignJWT({
scope: "altinn:enterprise/v1.read",
})
.setProtectedHeader({
alg: "RS256",
kid: process.env.MASKINPORTEN_KID!,
})
.setIssuer(process.env.MASKINPORTEN_CLIENT_ID!)
.setAudience(TEST_AUDIENCE)
.setIssuedAt(now)
.setExpirationTime(now + 60)
.setJti(randomUUID())
.sign(privateKey);Pitfall: exp must be ≤ 120 seconds in the future. now + 3600 is rejected.
Pitfall: aud is the exact Maskinporten environment URL with trailing slash. A missing slash is rejected.
Step 5: Exchange the assertion for an access token
const response = await fetch("https://test.maskinporten.no/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}),
});
const { access_token, expires_in, scope } = (await response.json()) as {
access_token: string;
expires_in: number;
scope: string;
};
// Cache until expires_in - 30s; do NOT request a new token per call.Common 400 responses you'll see:
invalid_client— JWK / kid / signature mismatch. Re-check that the JWK in Samarbeidsportalen hasalg: RS256anduse: sig, and thatkidin the JWT header matches.invalid_scope— the scope you asked for is not approved on this client. Apply for the scope in Samarbeidsportalen.invalid_grant— JWT structure issue. Most often the wrongaud(missing trailing slash) orexptoo far in the future.
What about System Users?
Maskinporten authenticates your organization to talk to Norwegian government APIs. System Users authenticate your organization to act on behalf of another organization — the "I'm filing this MVA return for my client company" case.
You need a System User any time you:
- File MVA, A-melding, or skattemelding on behalf of a client
- Fetch restricted Tier 2 data (employee count, turnover, balance-sheet) about a company that isn't yours
- Perform any delegated action — submission, approval, signing
The flow:
- The target company adds your organization as a System User in Altinn.
- The target company grants specific Altinn rights (e.g. Regnskapsfører) to that System User.
- Your Maskinporten access token now scopes those delegated permissions when used against Altinn endpoints.
Pitfall:System User created without rights. You can authenticate but you can't act. The two surfaces — "is the System User present?" and "does it have the right permissions?" — must be checked separately.
Date note: as of April 2026, System Users were on Digdir's published roadmap for general availability later that year. Roadmap dates move; always verify the current status at docs.altinn.studio before depending on it for a deadline.
Common pitfalls (the things that cost you a day each)
- Wrong audience claim. Test URL used against a prod client, or vice-versa. Or missing trailing slash.
- Wrong scope name. Case-sensitive, including the
:and/separators. Triple-check against the scope list. - Clock skew.Your server's clock is more than a few seconds off from Maskinporten's, so
iat/expvalidation fails. Run NTP. - JWK uploaded with
use: encinstead ofuse: sig. Silent verification failure. - Production URL used against test certificate (or vice-versa).
- Test client used after going live. Tokens are environment-scoped.
- Cert renewal forgotten. Annual. Set a calendar reminder 60 days out.
- bruksvilkår signed for the wrong legal entity.
- Missing
kidin JWT header even when JWK has one. Maskinporten won't guess. - Hard-coded token in a config file. They expire in 120 seconds. Cache in memory, not on disk.
How Apier handles Maskinporten
The "after" picture is one request:
curl https://apier.no/api/v1/company/999999999/obligations \
-H 'Authorization: Bearer apier_live_...'Behind that line, Apier runs the entire flow you've just read about — and a few things you'd have written next.
Key custody and assertion building. Apier holds the production virksomhetssertifikat and the JWK upload. Each incoming call triggers a fresh JWT assertion against the current private key, with the right kid (the key identifier registered with Digdir) and the scope set drawn from the endpoint called. Assertions are never cached.
Scope-aware token cache. Maskinporten access tokens live for 120 seconds. Apier caches them keyed on scope|kid, not scope alone. The reason is the rotation pitfall from Step 5: if the cache key omits the kid, a key rotation serves stale tokens for up to two minutes while every downstream call returns 401. Keying on both means the next call after a rotation requests a fresh token against the new key.
Retry budget on the token exchange. Maskinporten occasionally returns 5xx under load. Apier retries with a small budget — three attempts, exponential backoff — and surfaces the final failure as a structured SERVER_ERROR with failures[].field = "MASKINPORTEN_PRIVATE_KEY". You don't write the retry loop or guess at the error shape.
Audit trail in the response. Every Apier response carries a _metablock with the request ID, rulebook version, and the downstream APIs called on this request's behalf. When a regulator asks "what did you do on this customer's behalf on this date," the answer is in the audit log, indexed by request ID, append-only.
What still stays on your side
Apier removes the Maskinporten plumbing. It doesn't remove the legal relationship between you and the company you're acting for, and it doesn't remove your own credential hygiene.
The Altinn system user grant.When a Norwegian company wants you (via Apier) to file their MVA-melding or read their employee register, the company's authorized representative grants a system user in Altinn pointing at Apier. This is a one-time, two-minute process for the customer — and it's the legal consent surface that no infrastructure layer can paper over. See "What about System Users?" above for the mechanics.
Your Apier API key.Treat it the way you'd treat a Maskinporten private key: don't commit it, rotate it on a schedule, scope it per environment.
Your business logic and compliance posture. Apier returns the data; what you do with it is your call. Storage decisions, GDPR Article 30 documentation, retention policy — still yours. Apier is the auth-and-translation layer, not your application's compliance officer.
When direct integration still makes sense
Three cases where you should skip Apier and build the direct path:
You already operate Maskinporten infrastructure. Banks, accounting platforms, and ERP vendors with existing virksomhetssertifikat custody, key rotation runbooks, and Altinn integration teams have paid the setup cost. If Apier would duplicate what you already run, build the slice you need yourself.
Your security posture requires keys on your own infrastructure. Some operators are required by sector rules, customer contracts, or board-level policy to hold the Maskinporten private key inside their own perimeter. Apier's hosted custody model isn't compatible — and shouldn't be. Treat this post as your integration guide.
You need a scope Apier doesn't carry yet. Apier integrates the scopes our customers ask for most: altinn:serviceowner, the Skatteetaten MVA scopes, NAV's Aa-registeret. If the scope you need isn't on the list, either we'll add it (open an issue) or you'll move faster building it yourself for the one scope you need.
References
- Samarbeidsportalen — register clients, sign bruksvilkår, upload keys
- Maskinporten public docs — the canonical reference; this guide hedges where the docs give exact numbers
- Altinn 3 developer portal
- RFC 7519 (JWT)
- jose — the JWT library the code samples use
- This guide is open-source — open an issue or PR at PowerLaunch/apier.
Last updated: 2026-05-19.
Found a mistake? Open an issue.
Cite this as: Apier.no — Maskinporten Guide v1.0. Back to /blog.