Content licences are usually territorial. A streaming service can show a film in Germany and Austria but not in France. A sports rights deal covers one country. An e-book is licensed for some markets only. The service has to make sure content is only available where it's licensed, and the usual tool is IP geolocation.
In the EU there's a second rule that pulls in the other direction. Paying subscribers who are temporarily in another member state must be able to use their service as they do at home. And the way you determine their home country is regulated too.
This guide covers both and shows how to build them into one access check.
This is a technical guide, not legal advice. Licence terms and how the regulations apply to your service need review by people qualified in media and copyright law.
Two different questions
Keep these apart in your code, because they have different answers and different evidence:
- Where is the viewer right now? Used for territorial licensing. IP geolocation is the standard tool.
- Where does the subscriber live? Used for EU portability. Under the Portability Regulation, an IP check can't answer this on its own when the subscriber signs up.
Territorial licensing and the Geo-blocking Regulation
The EU's Geo-blocking Regulation (EU) 2018/302 restricts blocking and discriminating against customers from other member states. It largely leaves territorial content licensing alone:
- Article 1(3) excludes the activities listed in Article 2(2) of the Services Directive. These include audiovisual services, which recital 8 names explicitly.
- Article 1(5) says the Regulation doesn't affect copyright and neighbouring rights rules.
- Article 4(1)(b) covers electronically supplied services, but excludes those whose main feature is access to and use of copyright-protected works, such as e-books, music, games and software.
So restricting licensed content to its licensed territories is generally outside the Regulation's ban. Other parts of your business, such as selling merchandise or physical goods, may not be. See geo-blocking by country and EU law.
The EU Portability Regulation in brief
Regulation (EU) 2017/1128 on cross-border portability of online content services has applied since 2018. The parts that affect your code:
- Article 3(1): a provider of a paid online content service must enable a subscriber who is temporarily present in another member state to access and use the service in the same way as in their member state of residence. That includes the same content, the same range of devices and the same features. Article 3(2) adds that the provider can't charge extra for it.
- Article 2(4): "temporarily present" means being in a member state other than the one of residence "for a limited period of time". It's about travel, not moving abroad.
- Article 4: while the subscriber is temporarily present elsewhere, the provision of the service is deemed to occur solely in the member state of residence. That's what makes it compatible with your territorial licence.
- Article 6: providers of free services may opt in, after verifying the member state of residence. If they do, the Regulation applies to them.
How you may verify the member state of residence
Article 5(1) says the provider must verify the subscriber's member state of residence at conclusion and renewal of the contract, "by using not more than two" means from a list:
- (a) an identity card or other valid ID showing the member state;
- (b) payment details, such as the bank account or card number;
- (c) where a set-top box or similar device is installed;
- (d) payment of a licence fee for other services, such as public broadcasting;
- (e) an internet or phone contract linked to the member state;
- (f) registration on local electoral rolls;
- (g) payment of local taxes;
- (h) a utility bill;
- (i) a billing or postal address;
- (j) a declaration by the subscriber;
- (k) "an internet protocol (IP) address check".
The key rule for geolocation: means (i) to (k), including the IP check, can only be used in combination with one of (a) to (h). The exception is a postal address in a publicly available official register. An IP check on its own isn't enough to establish where a subscriber lives at sign-up.
Article 5(2) allows an exception during the contract. If the provider has reasonable doubts about the subscriber's member state of residence, it may repeat the verification, and then the IP check "may be used as a sole means". The data from such an IP check must be "collected in binary format only": whether the subscriber is in their member state of residence or not, not where exactly they are.
Article 8 limits the data. It may be used only for verification, and on completion of each verification "the data shall be immediately and irreversibly destroyed".
The access decision
Put together, a request for a title is allowed if:
- The viewer's current country is in the title's licensed territories, or
- Portability applies: the subscriber has a paid (or opted-in free) subscription, their verified member state of residence is in the title's licensed territories, and they are currently in another EU member state.
Everything else gets a clear "not available in your region" message. The second rule doesn't extend outside the EU. A German subscriber in the US gets whatever your US licences allow.
Two practical points:
- If the lookup fails or returns
"No Data", the current country is unknown. Rule 1 can't pass. Rule 2 still can, if you know the subscriber is in the EU. Usually you don't, so the safe default is to deny the title and show a message with a retry. Your licence terms may say exactly what's expected, so check them. - Use the EU member states for rule 2, and ask counsel whether Iceland, Liechtenstein and Norway also need to be included for your service through the EEA Agreement.
Node.js
const EU = new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT',
'LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']); // ISO code for Greece is GR
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
export async function currentCountry(ip) {
try {
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1500) });
return res.ok ? known((await res.json()).country_code) : null;
} catch {
return null;
}
}
/**
* title.territories: Set of ISO codes the title is licensed for
* sub: { portable: boolean, homeState: 'DE' | null } homeState verified at sign-up per Art. 5(1)
*/
export function canPlay(title, sub, country) {
if (country && title.territories.has(country)) return { ok: true, basis: 'licence' };
if (sub?.portable && sub.homeState && title.territories.has(sub.homeState) &&
country && EU.has(country) && country !== sub.homeState) {
return { ok: true, basis: 'portability' };
}
return { ok: false, basis: country ? 'territory' : 'unknown_location' };
}
// Express route
app.get('/watch/:id', async (req, res) => {
const title = await titles.get(req.params.id);
const country = await currentCountry(req.ip.replace(/^::ffff:/, ''));
const decision = canPlay(title, req.user?.subscription, country);
if (!decision.ok) {
res.set('Cache-Control', 'no-store');
return res.status(451).render('not-available', { reason: decision.basis });
}
res.render('player', { title });
});Check access when the playback session starts, and again when issuing the playback licence or signed media URLs, which are what actually unlock the content. A check only on the page that shows the player is easy to bypass.
Recording the binary check
For the Article 5(2) re-verification, keep only what the Regulation allows: a yes/no per check, not the country.
// Called on each playback start. Stores only "was the subscriber in their home state?".
export async function recordPresence(db, sub, country) {
if (!sub?.homeState || !country) return; // unknown: record nothing
await db.run('INSERT INTO presence_checks (subscription_id, in_home_state, day) VALUES (?, ?, date(\'now\'))',
sub.id, country === sub.homeState ? 1 : 0);
}How you decide what counts as "reasonable doubts" is a policy question for your legal team, for example a long run of days outside the home state. Write the threshold down, apply it the same way to everyone, and delete the records you no longer need for it.
Python
import os
import requests
EU = {"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT",
"LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"}
session = requests.Session()
def known(v):
return None if v in (None, "", "No Data") else v # missing fields are "No Data"
def current_country(ip: str) -> str | None:
try:
r = session.get(f"https://ipapi.gr/api/lookup/{ip}", params={"key": os.environ["IPAPI_KEY"]}, timeout=1.5)
except requests.RequestException:
return None
return known(r.json().get("country_code")) if r.status_code == 200 else None
def can_play(territories: set[str], portable: bool, home_state: str | None, country: str | None):
if country and country in territories:
return True, "licence"
if portable and home_state in territories and country in EU and country != home_state:
return True, "portability"
return False, "territory" if country else "unknown_location"PHP
<?php
const EU = ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT',
'LU','MT','NL','PL','PT','RO','SK','SI','ES','SE'];
function known($v) {
return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}
function current_country(string $ip): ?string {
$ch = curl_init('https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY')));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ($body !== false && $status === 200) ? known(json_decode($body, true)['country_code'] ?? null) : null;
}
function can_play(array $territories, bool $portable, ?string $home, ?string $country): array {
if ($country !== null && in_array($country, $territories, true)) return [true, 'licence'];
if ($portable && $home !== null && in_array($home, $territories, true)
&& $country !== null && in_array($country, EU, true) && $country !== $home) {
return [true, 'portability'];
}
return [false, $country === null ? 'unknown_location' : 'territory'];
}
[$ok, $basis] = can_play($title['territories'], $sub['portable'] ?? false, $sub['home_state'] ?? null,
current_country($_SERVER['REMOTE_ADDR']));
if (!$ok) {
http_response_code(451);
header('Cache-Control: no-store');
exit('This title is not available in your region.');
}Go
package rights
import (
"encoding/json"
"net/http"
"net/url"
"os"
"time"
)
var (
client = &http.Client{Timeout: 1500 * time.Millisecond}
eu = map[string]bool{"AT": true, "BE": true, "BG": true, "HR": true, "CY": true, "CZ": true, "DK": true, "EE": true,
"FI": true, "FR": true, "DE": true, "GR": true, "HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true,
"MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true, "SI": true, "ES": true, "SE": true}
)
func known(s string) string {
if s == "No Data" { // missing fields are "No Data"
return ""
}
return s
}
func CurrentCountry(ip string) string {
resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
if err != nil {
return ""
}
defer resp.Body.Close()
var g struct {
CountryCode string `json:"country_code"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&g) != nil {
return ""
}
return known(g.CountryCode)
}
type Subscription struct {
Portable bool
HomeState string // verified at sign-up, "" if none
}
func CanPlay(territories map[string]bool, sub Subscription, country string) (bool, string) {
if country != "" && territories[country] {
return true, "licence"
}
if sub.Portable && sub.HomeState != "" && territories[sub.HomeState] && eu[country] && country != sub.HomeState {
return true, "portability"
}
if country == "" {
return false, "unknown_location"
}
return false, "territory"
}At the edge: protect the media, not just the page
Players fetch manifests and segments directly, so the check has to happen where those are served. A common pattern: your origin signs a short-lived token at playback start, containing the decision, and an edge worker validates the token on every media request. If you want the edge to re-check location, for example to stop a token being shared across countries, look up the country there too:
// Cloudflare Worker in front of /media/*. verifyToken() is your HMAC check; the token carries
// { titleId, territories: ['DE','AT'], portableHome: 'DE' | null, exp }.
const EU = new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT',
'LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']);
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
export default {
async fetch(request, env) {
const token = await verifyToken(new URL(request.url).searchParams.get('t'), env.TOKEN_SECRET);
if (!token) return new Response('Forbidden', { status: 403 });
let country = null;
try {
const ip = request.headers.get('CF-Connecting-IP');
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1000) });
if (res.ok) country = known((await res.json()).country_code);
} catch { /* unknown */ }
const licensed = country && token.territories.includes(country);
const portable = token.portableHome && token.territories.includes(token.portableHome) && country && EU.has(country);
if (!licensed && !portable) return new Response('Not available in your region', { status: 451 });
return fetch(request);
}
};Looking up every segment request would be wasteful, since a video is hundreds of segments. Cache the decision per token and IP for a few minutes (Workers' Cache API or KV), or check only manifests. Workers' built-in request.cf.country is a cheaper alternative for a country-only check at this volume.
VPNs and what you can promise rights holders
Licence agreements often require "industry-standard" geo-restriction. IP geolocation is the baseline for that. It isn't proof against a determined user with a VPN, and IPAPI.GR doesn't detect VPNs, proxies or Tor. Some services add other signals, such as the account's verified home country, payment country and device information. Be accurate with rights holders about what your system does: country by IP, plus the account-level checks you've built.
What geolocation is good at here is the common case: ordinary viewers on ordinary connections, where country-level results are usually reliable.
Search engines
Title pages of geo-restricted content still need to be indexable. Keep the title page (description, artwork, cast) public everywhere, and restrict only the player and media. Google's guidance on locale-adaptive pages is to treat Googlebot like any other visitor from the country it appears to come from. Don't make special exceptions for crawlers on restricted content.
FAQ
Can I verify a subscriber's home country with an IP lookup alone?
Not at sign-up under the EU Portability Regulation. The IP check must be combined with one of the stronger means listed in Article 5(1)(a) to (h), such as payment details or ID. During the contract, if you have reasonable doubts, an IP check may be used on its own, collecting only "in the home state or not".
Does portability apply outside the EU?
No. It covers subscribers temporarily present in another EU member state. Travel outside the EU is governed by your licences.
Do free services have to offer portability?
No. Under Article 6 they may choose to, after verifying the member state of residence, and the Regulation then applies to them.
What should happen when the location is unknown?
For licensed content, the usual safe default is to deny the title, explain why and offer a retry. Your licence terms may say what's expected.
Related: geo-blocking by country and EU law · IPv6 geolocation.